From 1a94caf5be1c9ac853262096f3cbb91d0cd9a577 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 24 Mar 2026 21:42:14 +0100 Subject: [PATCH 1/7] feat(pypi): add pyproject.toml support to IntelliJ plugin Add component analysis support for pyproject.toml manifest files, enabling inline vulnerability diagnostics for Python projects using PEP 621 ([project.dependencies]) and Poetry ([tool.poetry.dependencies]) formats. New classes: - PyprojectCAAnnotator: extracts dependencies from TOML PSI structure - PyprojectCAIntentionAction: quick-fix for version updates - PyprojectCAInspection: inspection registration Modified files: - SaAction, SaUtils: recognize pyproject.toml as supported manifest - CAAnnotator, SAIntentionAction: add pyproject.toml to python mapping - ApiService: apply Python-specific settings for pyproject.toml - plugin.xml: register annotator and inspection for TOML language - README.md: document pyproject.toml support Implements TC-3853 Assisted-by: Claude Code --- README.md | 23 +- .../componentanalysis/CAAnnotator.java | 2 +- .../componentanalysis/SAIntentionAction.java | 3 +- .../pypi/PyprojectCAAnnotator.java | 335 ++++++++++++++++++ .../pypi/PyprojectCAInspection.java | 39 ++ .../pypi/PyprojectCAIntentionAction.java | 99 ++++++ .../tools/intellij/exhort/ApiService.java | 4 +- .../intellij/stackanalysis/SaAction.java | 3 +- .../tools/intellij/stackanalysis/SaUtils.java | 5 +- src/main/resources/META-INF/plugin.xml | 9 + 10 files changed, 513 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java create mode 100644 src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAInspection.java create mode 100644 src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java diff --git a/README.md b/README.md index 66bffd1d..90d0f28c 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`, only production dependencies from `[project.dependencies]` (PEP 621) and + `[tool.poetry.dependencies]` are analyzed. Optional dependencies (`[project.optional-dependencies]`) + and 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/pypi/PyprojectCAAnnotator.java b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java new file mode 100644 index 00000000..ad760064 --- /dev/null +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java @@ -0,0 +1,335 @@ +/******************************************************************************* + * 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.VulnerabilitySource; +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 ("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) { + return element instanceof TomlKeyValue && + element.getContainingFile() != null && + PYPROJECT_TOML.equals(element.getContainingFile().getName()); + } + + /** + * Parses PEP 621 [project] dependencies array. + * Format: dependencies = ["anyio==3.6.2", "flask>=2.0.3"] + */ + 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); + // Use the parent TomlKeyValue of the array as element for annotation, + // but we need to find or create a suitable element. Use the literal itself + // wrapped through the array's parent key-value. + resultMap.computeIfAbsent(dp, k -> new LinkedList<>()).add(kv); + } + } + } + + /** + * 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) { + // Check if comment is on same line as a TomlKeyValue + TomlKeyValue keyValue = findKeyValueOnSameLine(file, comment); + if (keyValue != null) { + String keyName = keyValue.getKey().getText(); + // If in [tool.poetry.dependencies], the key IS the dependency name + if (isInPoetryDependencies(keyValue)) { + return normalizeKeyName(keyName); + } + // If key is "dependencies" in [project], check if on same line as an array element + // For PEP 621 array entries, the ignore comment is typically on the same line as the string literal + } + + // 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 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; + } + + private 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 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(); + } + } + 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..ea199a90 --- /dev/null +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * 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 TomlKeyValue keyValue) { + TomlLiteral valueLiteral = findVersionLiteral(keyValue); + if (valueLiteral != null) { + replaceVersionLiteral(project, file, valueLiteral, "==" + version); + } + } + } + + private TomlLiteral findVersionLiteral(TomlKeyValue keyValue) { + if (keyValue.getValue() instanceof TomlLiteral literal) { + // Simple string: "anyio==3.6.2" — this is a PEP 508 array entry, version is embedded in the string + // For array entries, the whole literal is the dependency string + return literal; + } + if (keyValue.getValue() instanceof TomlInlineTable inlineTable) { + // Poetry format: 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; + } +} 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 @@ + + + + + From 604b465d504cd1151c481bcf4794fd183c1157cb Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 24 Mar 2026 22:55:21 +0100 Subject: [PATCH 2/7] fix(pypi): address Qodo review findings for pyproject.toml support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix PEP 621 annotation targeting: use individual TomlLiteral elements instead of parent TomlKeyValue so each dependency gets its own highlight - Fix quick-fix replacement: preserve package name in PEP 508 strings (e.g. "anyio==3.6.2" → "anyio==4.0.0" instead of just "==4.0.0") - Add [project.optional-dependencies] parsing for PEP 621 optional groups - Update README to reflect optional-dependencies are now analyzed Implements TC-3853 Co-Authored-By: Claude Opus 4.6 Assisted-by: Claude Code --- README.md | 6 +-- .../pypi/PyprojectCAAnnotator.java | 53 ++++++++++++++----- .../pypi/PyprojectCAIntentionAction.java | 18 +++++-- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 90d0f28c..b8a533c5 100644 --- a/README.md +++ b/README.md @@ -380,9 +380,9 @@ 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`, only production dependencies from `[project.dependencies]` (PEP 621) and - `[tool.poetry.dependencies]` are analyzed. Optional dependencies (`[project.optional-dependencies]`) - and Poetry group dependencies (`[tool.poetry.group.*.dependencies]`) are excluded. + 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** 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 index ad760064..c4734020 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java @@ -80,6 +80,8 @@ protected Map> getDependencies(PsiFile file) { 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); } @@ -100,15 +102,17 @@ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, Depe @Override protected boolean isQuickFixApplicable(PsiElement element) { - return element instanceof TomlKeyValue && - element.getContainingFile() != null && - PYPROJECT_TOML.equals(element.getContainingFile().getName()); + 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; } - /** - * Parses PEP 621 [project] dependencies array. - * Format: dependencies = ["anyio==3.6.2", "flask>=2.0.3"] - */ private void parsePep621Dependencies(TomlTable projectTable, Set ignoredDeps, Map> resultMap) { List keyValues = PsiTreeUtil.getChildrenOfTypeAsList(projectTable, TomlKeyValue.class); @@ -131,10 +135,35 @@ private void parsePep621Dependencies(TomlTable projectTable, Set ignored } String version = extractPep508Version(depString); Dependency dp = new Dependency(PYPI, null, name.toLowerCase(), version); - // Use the parent TomlKeyValue of the array as element for annotation, - // but we need to find or create a suitable element. Use the literal itself - // wrapped through the array's parent key-value. - resultMap.computeIfAbsent(dp, k -> new LinkedList<>()).add(kv); + 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); } } } @@ -280,7 +309,7 @@ private String normalizeKeyName(String keyText) { return keyText; } - private static String unquote(String text) { + static String unquote(String text) { if (text != null && text.length() >= 2) { if ((text.startsWith("\"") && text.endsWith("\"")) || (text.startsWith("'") && text.endsWith("'"))) { 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 index ea199a90..cb83b2c6 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java @@ -40,7 +40,15 @@ protected void updateVersion(@NotNull Project project, Editor editor, PsiFile fi return; } - if (element instanceof TomlKeyValue keyValue) { + 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) { + replaceVersionLiteral(project, file, literal, name + "==" + version); + } + } 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); @@ -50,12 +58,11 @@ protected void updateVersion(@NotNull Project project, Editor editor, PsiFile fi private TomlLiteral findVersionLiteral(TomlKeyValue keyValue) { if (keyValue.getValue() instanceof TomlLiteral literal) { - // Simple string: "anyio==3.6.2" — this is a PEP 508 array entry, version is embedded in the string - // For array entries, the whole literal is the dependency string + // Poetry simple string: anyio = "^3.6.2" return literal; } if (keyValue.getValue() instanceof TomlInlineTable inlineTable) { - // Poetry format: anyio = {version = "^3.6.2"} + // 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(); @@ -94,6 +101,7 @@ private void replaceVersionLiteral(@NotNull Project project, PsiFile file, TomlL @Override public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) { - return file != null && "pyproject.toml".equals(file.getName()) && element instanceof TomlKeyValue; + return file != null && "pyproject.toml".equals(file.getName()) + && (element instanceof TomlKeyValue || element instanceof TomlLiteral); } } From 376d0cdd8e6111988d9feedeb427bdc52fd994e5 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 31 Mar 2026 14:53:26 +0200 Subject: [PATCH 3/7] fix(pypi): preserve PEP 508 extras and markers in quick fix and version extraction The version extraction regex was matching across the ';' environment marker boundary, and the quick fix was dropping extras and markers when rewriting the dependency string. Co-Authored-By: Claude Opus 4.6 --- .../pypi/PyprojectCAAnnotator.java | 35 +++++++++++++++++++ .../pypi/PyprojectCAIntentionAction.java | 12 ++++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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 index c4734020..ce438828 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java @@ -330,6 +330,36 @@ static String extractPep508Name(String depString) { 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; @@ -347,6 +377,11 @@ static String extractPep508Version(String depString) { 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; } 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 index cb83b2c6..bff739da 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionAction.java @@ -45,7 +45,17 @@ protected void updateVersion(@NotNull Project project, Editor editor, PsiFile fi String depString = PyprojectCAAnnotator.unquote(literal.getText()); String name = PyprojectCAAnnotator.extractPep508Name(depString); if (name != null) { - replaceVersionLiteral(project, file, literal, name + "==" + version); + 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" From 773728940db74d2e0c6ffa83ebae5fc35bb339cd Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 1 Apr 2026 11:55:25 +0200 Subject: [PATCH 4/7] chore: fail fast when the toml file is not Cargo.toml Signed-off-by: Ruben Romero Montes --- .../intellij/componentanalysis/cargo/CargoCAAnnotator.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 +} From a1c172727e9af9a831aa3bea72be6ff17cbf1022 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 20 Apr 2026 23:55:36 +0200 Subject: [PATCH 5/7] fix: implement license abstract methods Signed-off-by: Ruben Romero Montes --- .../componentanalysis/pypi/PyprojectCAAnnotator.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index ce438828..6a21f182 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java @@ -22,7 +22,9 @@ 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; @@ -113,6 +115,16 @@ protected boolean isQuickFixApplicable(PsiElement element) { 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); From 50bd8ee60a54a98afd58fe230ac92aae584a8d9e Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 21 Apr 2026 11:05:53 +0200 Subject: [PATCH 6/7] test(pypi): add unit tests for pyproject.toml support Add 35 tests covering PEP 508 utility methods, PEP 621 and Poetry dependency extraction, optional dependencies, intention action version updates, availability checks, and SaAction/SaUtils manifest recognition. Implements TC-4172 Co-Authored-By: Claude Opus 4.6 Assisted-by: Claude Code --- .../pypi/PyprojectCAAnnotatorTest.java | 378 ++++++++++++++++++ .../pypi/PyprojectCAIntentionActionTest.java | 264 ++++++++++++ 2 files changed, 642 insertions(+) create mode 100644 src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java create mode 100644 src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAIntentionActionTest.java 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..9fb4fdbb --- /dev/null +++ b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java @@ -0,0 +1,378 @@ +/******************************************************************************* + * 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()); + } + + // ── 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)); + } +} From 78bd4876f8fc63da674027fb097fcc2cb0d52ced Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 21 Apr 2026 11:25:30 +0200 Subject: [PATCH 7/7] fix(pypi): resolve Poetry ignore comment misbind for multi-line inline tables When a trustify-da-ignore comment was placed on an inner key of a multi-line Poetry inline table, findAssociatedDependency() returned the inner key name (e.g., "version") instead of the outer dependency name. Added resolvePoetryDependencyEntry() to walk up from inner TomlKeyValue through TomlInlineTable to the actual dependency entry. Resolves: TC-4173 Co-Authored-By: Claude Opus 4.6 --- .../pypi/PyprojectCAAnnotator.java | 21 +++-- .../pypi/PyprojectCAAnnotatorTest.java | 80 +++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) 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 index 6a21f182..9ede9c8b 100644 --- a/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java +++ b/src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotator.java @@ -228,16 +228,12 @@ private Set getIgnoredDependencies(PsiFile file) { } private String findAssociatedDependency(PsiFile file, PsiComment comment) { - // Check if comment is on same line as a TomlKeyValue TomlKeyValue keyValue = findKeyValueOnSameLine(file, comment); if (keyValue != null) { - String keyName = keyValue.getKey().getText(); - // If in [tool.poetry.dependencies], the key IS the dependency name if (isInPoetryDependencies(keyValue)) { - return normalizeKeyName(keyName); + TomlKeyValue depEntry = resolvePoetryDependencyEntry(keyValue); + return normalizeKeyName(depEntry.getKey().getText()); } - // If key is "dependencies" in [project], check if on same line as an array element - // For PEP 621 array entries, the ignore comment is typically on the same line as the string literal } // Check array elements — PEP 621 format where comment is on the same line as a literal in the array @@ -262,6 +258,19 @@ private boolean isInPoetryDependencies(TomlKeyValue keyValue) { 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); 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 index 9fb4fdbb..ce1bdaa9 100644 --- a/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java +++ b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java @@ -331,6 +331,86 @@ public void testPyprojectWithNoDependencies() { 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. */