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)); + } +}