rawLines, String searchToken, String depIdentifier) {
- for (String line : rawLines) {
- if (line.contains(searchToken) && containsIgnorePattern(line)) {
- collectedIgnoredDeps.add(depIdentifier);
- break;
- }
- }
- }
-
static final class PipPackage {
final String name;
final String version;
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java
new file mode 100644
index 00000000..60311a70
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java
@@ -0,0 +1,412 @@
+/*
+ * Copyright 2023-2025 Trustify Dependency Analytics Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.guacsec.trustifyda.providers;
+
+import com.github.packageurl.PackageURL;
+import io.github.guacsec.trustifyda.Api;
+import io.github.guacsec.trustifyda.license.LicenseUtils;
+import io.github.guacsec.trustifyda.logging.LoggersFactory;
+import io.github.guacsec.trustifyda.sbom.Sbom;
+import io.github.guacsec.trustifyda.sbom.SbomFactory;
+import io.github.guacsec.trustifyda.tools.Operations;
+import io.github.guacsec.trustifyda.utils.Environment;
+import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
+import io.github.guacsec.trustifyda.utils.PythonControllerBase;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.tomlj.TomlParseResult;
+
+/**
+ * Provider for Python projects using {@code pyproject.toml} with the uv package manager.
+ *
+ * Dependency resolution is performed via {@code uv export --format requirements.txt --frozen
+ * --no-hashes --no-dev --no-emit-project}, which produces a requirements.txt with {@code # via}
+ * comments encoding the full dependency graph. The provider is selected by {@link
+ * PythonProviderFactory} when {@code uv.lock} is present alongside the manifest.
+ */
+public final class PythonUvProvider extends PythonProvider {
+
+ private static final Logger log = LoggersFactory.getLogger(PythonUvProvider.class.getName());
+
+ public static final String LOCK_FILE = "uv.lock";
+ static final String PROP_TRUSTIFY_DA_UV_EXPORT = "TRUSTIFY_DA_UV_EXPORT";
+
+ private final String uvExecutable;
+ private Set collectedIgnoredDeps;
+ private TomlParseResult cachedToml;
+
+ public PythonUvProvider(Path manifest) {
+ super(manifest);
+ this.uvExecutable = Operations.getExecutable("uv", "--version");
+ }
+
+ @Override
+ public void validateLockFile(Path lockFileDir) {
+ // Check manifest directory first, then walk up to workspace root
+ if (Files.isRegularFile(lockFileDir.resolve(LOCK_FILE))) {
+ return;
+ }
+ Path parentDir = PythonProviderFactory.findLockFileDirInParents(lockFileDir);
+ if (parentDir != null && Files.isRegularFile(parentDir.resolve(LOCK_FILE))) {
+ return;
+ }
+ throw new IllegalStateException(
+ "uv.lock does not exist. Ensure the project is managed by uv"
+ + " and run 'uv lock' to generate it.");
+ }
+
+ @Override
+ public Content provideStack() throws IOException {
+ rejectPoetryDependencies();
+ collectIgnoredDeps();
+ Path manifestDir = manifestPath.toAbsolutePath().getParent();
+ String exportOutput = getUvExportOutput(manifestDir);
+ UvDependencyData data = parseUvExport(exportOutput);
+
+ Sbom sbom = SbomFactory.newInstance(Sbom.BelongingCondition.PURL, "sensitive");
+ sbom.addRoot(
+ toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
+
+ for (String directKey : data.directDeps()) {
+ UvPackage pkg = data.graph().get(directKey);
+ if (pkg != null) {
+ addDependencyTree(sbom.getRoot(), pkg, data.graph(), sbom, new HashSet<>());
+ }
+ }
+
+ String manifestContent = Files.readString(manifestPath);
+ handleIgnoredDependencies(manifestContent, sbom);
+ return new Content(
+ sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
+ }
+
+ @Override
+ public Content provideComponent() throws IOException {
+ rejectPoetryDependencies();
+ collectIgnoredDeps();
+ Path manifestDir = manifestPath.toAbsolutePath().getParent();
+ String exportOutput = getUvExportOutput(manifestDir);
+ UvDependencyData data = parseUvExport(exportOutput);
+
+ Sbom sbom = SbomFactory.newInstance();
+ sbom.addRoot(
+ toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
+
+ for (String key : data.directDeps()) {
+ UvPackage pkg = data.graph().get(key);
+ if (pkg != null) {
+ sbom.addDependency(sbom.getRoot(), toPurl(pkg.name(), pkg.version()), null);
+ }
+ }
+
+ String manifestContent = Files.readString(manifestPath);
+ handleIgnoredDependencies(manifestContent, sbom);
+ return new Content(
+ sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
+ }
+
+ private void addDependencyTree(
+ PackageURL source,
+ UvPackage pkg,
+ Map graph,
+ Sbom sbom,
+ Set visited) {
+ PackageURL packageURL = toPurl(pkg.name(), pkg.version());
+ sbom.addDependency(source, packageURL, null);
+
+ String key = PyprojectTomlUtils.canonicalize(pkg.name());
+ if (!visited.add(key)) {
+ return;
+ }
+
+ for (String childKey : pkg.children()) {
+ UvPackage child = graph.get(childKey);
+ if (child != null) {
+ addDependencyTree(packageURL, child, graph, sbom, visited);
+ }
+ }
+ }
+
+ /**
+ * Parses the output of {@code uv export --format requirements.txt} into a dependency graph.
+ *
+ * Each package line has the form {@code name==version} optionally followed by environment
+ * markers. Dependency relationships are encoded in {@code # via} comments that follow each
+ * package. A package whose {@code # via} parent is the project name is a direct dependency.
+ */
+ UvDependencyData parseUvExport(String exportOutput) throws IOException {
+ String projectName = PyprojectTomlUtils.canonicalize(getRootComponentName());
+ Map packages = new HashMap<>();
+ List directDeps = new ArrayList<>();
+ List parentChildPairs = new ArrayList<>();
+
+ String currentKey = null;
+ boolean inViaBlock = false;
+
+ for (String line : exportOutput.split("\\r?\\n")) {
+ String trimmed = line.trim();
+
+ if (trimmed.isEmpty()) {
+ inViaBlock = false;
+ continue;
+ }
+
+ // Skip top-level comments (header lines)
+ if (line.startsWith("#")) {
+ continue;
+ }
+
+ // Editable installs are workspace members — resolve name/version from their pyproject.toml
+ if (line.startsWith("-e ")) {
+ inViaBlock = false;
+ currentKey = parseEditableInstall(line, packages, projectName);
+ continue;
+ }
+
+ // Package line: name==version [; env-marker]
+ if (!line.startsWith(" ") && !trimmed.startsWith("#")) {
+ inViaBlock = false;
+ if (!trimmed.contains("==")) {
+ throw new IOException("uv export: package '" + trimmed + "' has no pinned version");
+ }
+ String spec = trimmed.split(";")[0].trim();
+ String[] parts = spec.split("==", 2);
+ String name = parts[0].split("\\[")[0].trim(); // strip extras like [bar]
+ String version = parts[1].trim();
+ currentKey = PyprojectTomlUtils.canonicalize(name);
+ packages.put(currentKey, new UvPackage(name, version, new ArrayList<>()));
+ continue;
+ }
+
+ // Indented "# via ..." line
+ if (trimmed.startsWith("# via") && currentKey != null) {
+ inViaBlock = true;
+ String rest = trimmed.substring("# via".length()).trim();
+ if (!rest.isEmpty()) {
+ recordViaParent(rest, currentKey, projectName, directDeps, parentChildPairs);
+ }
+ continue;
+ }
+
+ // Multi-line via continuation: "# parent-name"
+ if (inViaBlock && trimmed.startsWith("#") && currentKey != null) {
+ String parent = trimmed.substring(1).trim();
+ if (!parent.isEmpty()) {
+ recordViaParent(parent, currentKey, projectName, directDeps, parentChildPairs);
+ }
+ }
+ }
+
+ // Resolve parent→child relationships
+ for (String[] pair : parentChildPairs) {
+ UvPackage parent = packages.get(pair[0]);
+ if (parent != null && !parent.children().contains(pair[1])) {
+ parent.children().add(pair[1]);
+ }
+ }
+
+ return new UvDependencyData(directDeps, packages);
+ }
+
+ /**
+ * Parses an editable install line ({@code -e file:///path/to/member}) by reading the member's
+ * {@code pyproject.toml} to extract name and version. Skips the root project itself and packages
+ * missing either name or version, matching the JS client behavior.
+ *
+ * @return the canonicalized package key, or {@code null} if the member could not be resolved
+ */
+ private static String parseEditableInstall(
+ String line, Map packages, String projectName) {
+ String uri = line.substring("-e ".length()).trim();
+ try {
+ Path memberDir = Path.of(URI.create(uri));
+ Path memberToml = memberDir.resolve("pyproject.toml");
+ if (!Files.isRegularFile(memberToml)) {
+ log.fine("Editable install pyproject.toml not found: " + memberToml);
+ return null;
+ }
+ TomlParseResult toml = PyprojectTomlUtils.parseToml(memberToml);
+ String name = PyprojectTomlUtils.getProjectName(toml);
+ if (name == null) {
+ // Fall back to Poetry name
+ name = PyprojectTomlUtils.getPoetryProjectName(toml);
+ }
+ if (name == null) {
+ log.fine("Editable install has no project.name: " + memberToml);
+ return null;
+ }
+ String version = PyprojectTomlUtils.getProjectVersion(toml);
+ if (version == null) {
+ // Fall back to Poetry version
+ version = PyprojectTomlUtils.getPoetryProjectVersion(toml);
+ }
+ if (version == null) {
+ log.fine("Editable install has no project.version: " + memberToml);
+ return null;
+ }
+ String key = PyprojectTomlUtils.canonicalize(name);
+ // Skip the root project itself
+ if (key.equals(projectName)) {
+ return null;
+ }
+ packages.put(key, new UvPackage(name, version, new ArrayList<>()));
+ return key;
+ } catch (Exception e) {
+ log.fine("Failed to resolve editable install '" + uri + "': " + e.getMessage());
+ return null;
+ }
+ }
+
+ private static final Pattern BARE_PACKAGE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*");
+
+ private static void recordViaParent(
+ String parentName,
+ String childKey,
+ String projectName,
+ List directDeps,
+ List parentChildPairs) {
+ if (!BARE_PACKAGE_NAME.matcher(parentName).matches()) {
+ return;
+ }
+ String parentKey = PyprojectTomlUtils.canonicalize(parentName);
+ if (parentKey.equals(projectName)) {
+ if (!directDeps.contains(childKey)) {
+ directDeps.add(childKey);
+ }
+ } else {
+ parentChildPairs.add(new String[] {parentKey, childKey});
+ }
+ }
+
+ String getUvExportOutput(Path manifestDir) {
+ String envValue = Environment.get(PROP_TRUSTIFY_DA_UV_EXPORT);
+ if (envValue != null && !envValue.isBlank()) {
+ return envValue;
+ }
+
+ String[] cmd = {
+ uvExecutable,
+ "export",
+ "--format",
+ "requirements.txt",
+ "--frozen",
+ "--no-hashes",
+ "--no-dev",
+ "--no-emit-project"
+ };
+ Operations.ProcessExecOutput result =
+ Operations.runProcessGetFullOutput(manifestDir, cmd, null);
+ if (result.getExitCode() != 0) {
+ throw new RuntimeException(
+ String.format(
+ "uv export command failed with exit code %d: %s",
+ result.getExitCode(), result.getError()));
+ }
+ return result.getOutput();
+ }
+
+ // --- TOML parsing (shared with PythonPyprojectProvider) ---
+
+ private TomlParseResult getToml() throws IOException {
+ if (cachedToml == null) {
+ cachedToml = PyprojectTomlUtils.parseToml(manifestPath);
+ }
+ return cachedToml;
+ }
+
+ @Override
+ protected String getRootComponentName() {
+ try {
+ String name = PyprojectTomlUtils.getProjectName(getToml());
+ if (name != null) {
+ return name;
+ }
+ } catch (IOException e) {
+ log.fine("Failed to parse pyproject.toml for root component name: " + e.getMessage());
+ }
+ return super.getRootComponentName();
+ }
+
+ @Override
+ protected String getRootComponentVersion() {
+ try {
+ String version = PyprojectTomlUtils.getProjectVersion(getToml());
+ if (version != null) {
+ return version;
+ }
+ } catch (IOException e) {
+ log.fine("Failed to parse pyproject.toml for root component version: " + e.getMessage());
+ }
+ return super.getRootComponentVersion();
+ }
+
+ @Override
+ public String readLicenseFromManifest() {
+ try {
+ String license = PyprojectTomlUtils.getLicense(getToml());
+ if (license != null) {
+ return license;
+ }
+ } catch (IOException e) {
+ log.fine("Failed to parse pyproject.toml for license: " + e.getMessage());
+ }
+ return LicenseUtils.readLicenseFile(manifestPath);
+ }
+
+ @Override
+ protected Set getIgnoredDependencies(String manifestContent) {
+ if (collectedIgnoredDeps == null) {
+ return Set.of();
+ }
+ return collectedIgnoredDeps.stream()
+ .map(
+ dep -> {
+ String name = PythonControllerBase.getDependencyName(dep);
+ return toPurl(name, "*");
+ })
+ .collect(Collectors.toSet());
+ }
+
+ private void rejectPoetryDependencies() throws IOException {
+ if (PyprojectTomlUtils.hasPoetryDependencies(getToml())) {
+ throw new IllegalStateException(
+ "Poetry dependencies in pyproject.toml are not supported."
+ + " Please use PEP 621 [project.dependencies] format instead.");
+ }
+ }
+
+ private void collectIgnoredDeps() throws IOException {
+ collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifestPath, getToml());
+ }
+
+ record UvPackage(String name, String version, List children) {}
+
+ record UvDependencyData(List directDeps, Map graph) {}
+}
diff --git a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java
index 16650f79..3f6844fc 100644
--- a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java
+++ b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java
@@ -23,7 +23,7 @@
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
import io.github.guacsec.trustifyda.providers.JavaScriptProviderFactory;
import io.github.guacsec.trustifyda.providers.PythonPipProvider;
-import io.github.guacsec.trustifyda.providers.PythonPyprojectProvider;
+import io.github.guacsec.trustifyda.providers.PythonProviderFactory;
import java.nio.file.Path;
/** Utility class used for instantiating providers. * */
@@ -86,7 +86,7 @@ private static Provider resolveProvider(final Path manifestPath) {
case "package.json" -> JavaScriptProviderFactory.create(manifestPath);
case "go.mod" -> new GoModulesProvider(manifestPath);
case "requirements.txt" -> new PythonPipProvider(manifestPath);
- case "pyproject.toml" -> new PythonPyprojectProvider(manifestPath);
+ case "pyproject.toml" -> PythonProviderFactory.create(manifestPath);
case "build.gradle", "build.gradle.kts" -> new GradleProvider(manifestPath);
case "Cargo.toml" -> new CargoProvider(manifestPath);
default ->
diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java
new file mode 100644
index 00000000..edd2b2bc
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2023-2025 Trustify Dependency Analytics Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.guacsec.trustifyda.utils;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.tomlj.Toml;
+import org.tomlj.TomlArray;
+import org.tomlj.TomlParseResult;
+
+/**
+ * Shared utilities for Python providers that use {@code pyproject.toml} manifests. Provides TOML
+ * parsing, ignore-pattern collection, and metadata extraction used by both {@code
+ * PythonPyprojectProvider} and {@code PythonUvProvider}.
+ */
+public final class PyprojectTomlUtils {
+
+ private PyprojectTomlUtils() {}
+
+ /** Parses a {@code pyproject.toml} file and returns the result. */
+ public static TomlParseResult parseToml(Path manifest) throws IOException {
+ TomlParseResult parsed = Toml.parse(manifest);
+ if (parsed.hasErrors()) {
+ throw new IOException(
+ "Invalid pyproject.toml format: " + parsed.errors().get(0).getMessage());
+ }
+ return parsed;
+ }
+
+ /**
+ * Collects dependency identifiers marked with ignore patterns ({@code #exhortignore} or {@code
+ * #trustify-da-ignore}) in the {@code [project.dependencies]} section.
+ *
+ * @param manifest the path to the pyproject.toml file
+ * @param toml the parsed TOML result
+ * @return set of raw dependency strings that are marked as ignored
+ */
+ public static Set collectIgnoredDeps(Path manifest, TomlParseResult toml)
+ throws IOException {
+ List rawLines = Files.readAllLines(manifest);
+ Set ignored = new HashSet<>();
+
+ TomlArray projectDeps = toml.getArray("project.dependencies");
+ if (projectDeps != null) {
+ for (int i = 0; i < projectDeps.size(); i++) {
+ String dep = projectDeps.getString(i);
+ for (String line : rawLines) {
+ if (line.contains(dep) && IgnorePatternDetector.containsIgnorePattern(line)) {
+ ignored.add(dep);
+ break;
+ }
+ }
+ }
+ }
+ return ignored;
+ }
+
+ /** Reads {@code project.name} from a parsed pyproject.toml, or {@code null} if absent. */
+ public static String getProjectName(TomlParseResult toml) {
+ String name = toml.getString("project.name");
+ return (name != null && !name.isBlank()) ? name : null;
+ }
+
+ /** Reads {@code project.version} from a parsed pyproject.toml, or {@code null} if absent. */
+ public static String getProjectVersion(TomlParseResult toml) {
+ String version = toml.getString("project.version");
+ return (version != null && !version.isBlank()) ? version : null;
+ }
+
+ /**
+ * Reads the license from a parsed pyproject.toml. Checks {@code project.license} first, then
+ * {@code project.license.text} (PEP 639).
+ *
+ * @return the license string, or {@code null} if not found
+ */
+ public static String getLicense(TomlParseResult toml) {
+ String license = toml.getString("project.license");
+ if (license != null && !license.isBlank()) {
+ return license;
+ }
+ String licenseText = toml.getString("project.license.text");
+ return (licenseText != null && !licenseText.isBlank()) ? licenseText : null;
+ }
+
+ /** Returns {@code true} if the manifest contains {@code [tool.poetry.dependencies]}. */
+ public static boolean hasPoetryDependencies(TomlParseResult toml) {
+ return toml.getTable("tool.poetry.dependencies") != null;
+ }
+
+ /** Reads {@code tool.poetry.name} from a parsed pyproject.toml, or {@code null} if absent. */
+ public static String getPoetryProjectName(TomlParseResult toml) {
+ String name = toml.getString("tool.poetry.name");
+ return (name != null && !name.isBlank()) ? name : null;
+ }
+
+ /** Reads {@code tool.poetry.version} from a parsed pyproject.toml, or {@code null} if absent. */
+ public static String getPoetryProjectVersion(TomlParseResult toml) {
+ String version = toml.getString("tool.poetry.version");
+ return (version != null && !version.isBlank()) ? version : null;
+ }
+
+ /**
+ * Canonicalizes a Python package name by lower-casing it and collapsing runs of hyphens,
+ * underscores, and dots into a single hyphen, per PEP 503.
+ */
+ public static String canonicalize(String name) {
+ return name.toLowerCase().replaceAll("[-_.]+", "-");
+ }
+
+ /**
+ * Returns {@code true} if the directory contains a {@code pyproject.toml} with a {@code
+ * [tool.uv.workspace]} section, indicating a uv workspace root.
+ */
+ public static boolean isUvWorkspaceRoot(Path dir) {
+ Path pyprojectPath = dir.resolve("pyproject.toml");
+ if (!Files.isRegularFile(pyprojectPath)) {
+ return false;
+ }
+ try {
+ TomlParseResult toml = Toml.parse(pyprojectPath);
+ return toml.getTable("tool.uv.workspace") != null;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java b/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java
index b08c204e..cc367bc1 100644
--- a/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java
+++ b/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java
@@ -380,6 +380,14 @@ protected String getDependencyNameShow(String pipShowOutput) {
public static String getDependencyName(String dep) {
int markerSeparator = dep.indexOf(";");
String requirement = markerSeparator == -1 ? dep : dep.substring(0, markerSeparator);
+ // Strip PEP 508 extras (e.g. "requests[security]" -> "requests")
+ int extrasStart = requirement.indexOf("[");
+ if (extrasStart != -1) {
+ int extrasEnd = requirement.indexOf("]", extrasStart);
+ if (extrasEnd != -1) {
+ requirement = requirement.substring(0, extrasStart) + requirement.substring(extrasEnd + 1);
+ }
+ }
int rightTriangleBracket = requirement.indexOf(">");
int leftTriangleBracket = requirement.indexOf("<");
int equalsSign = requirement.indexOf("=");
diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java b/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java
new file mode 100644
index 00000000..0deeca37
--- /dev/null
+++ b/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2023-2025 Trustify Dependency Analytics Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.guacsec.trustifyda.providers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junitpioneer.jupiter.ClearSystemProperty;
+
+/**
+ * Tests for PythonProviderFactory lock file walk-up. Verifies that the factory can find uv.lock in
+ * parent directories for workspace member packages.
+ */
+class PythonProviderFactoryLockFileTest {
+
+ private static final String PYPROJECT_TOML =
+ "[project]\nname = \"my-lib\"\nversion = \"1.0.0\"\ndependencies = []\n";
+
+ /** Lock file in manifest directory (existing behavior — fast path). */
+ @Test
+ void testLockFileInManifestDir(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("pyproject.toml"), PYPROJECT_TOML);
+ Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
+
+ var provider = PythonProviderFactory.create(tempDir.resolve("pyproject.toml"));
+ assertThat(provider).isInstanceOf(PythonUvProvider.class);
+ }
+
+ /** No lock file — falls back to pip provider. */
+ @Test
+ void testNoLockFileFallsToPip(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("pyproject.toml"), PYPROJECT_TOML);
+
+ var provider = PythonProviderFactory.create(tempDir.resolve("pyproject.toml"));
+ assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
+ }
+
+ /** Lock file at workspace root, member pyproject.toml in subdirectory. */
+ @Test
+ void testLockFileFoundInParentDir(@TempDir Path tempDir) throws IOException {
+ // Workspace root with uv workspace config and lock file
+ Files.writeString(
+ tempDir.resolve("pyproject.toml"),
+ "[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n"
+ + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
+ Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
+
+ // Member package without its own lock file
+ Path memberDir = tempDir.resolve("packages/my-lib");
+ Files.createDirectories(memberDir);
+ Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
+
+ var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml"));
+ assertThat(provider).isInstanceOf(PythonUvProvider.class);
+ }
+
+ /** TRUSTIFY_DA_WORKSPACE_DIR overrides walk-up. */
+ @Test
+ @ClearSystemProperty(key = "TRUSTIFY_DA_WORKSPACE_DIR")
+ void testWorkspaceDirOverride(@TempDir Path tempDir) throws IOException {
+ // Custom dir with uv lock file
+ Path customDir = tempDir.resolve("custom-root");
+ Files.createDirectories(customDir);
+ Files.writeString(customDir.resolve("uv.lock"), "version = 1");
+
+ // Member without lock file
+ Path memberDir = tempDir.resolve("packages/svc");
+ Files.createDirectories(memberDir);
+ Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
+
+ System.setProperty("TRUSTIFY_DA_WORKSPACE_DIR", customDir.toString());
+ try {
+ var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml"));
+ assertThat(provider).isInstanceOf(PythonUvProvider.class);
+ } finally {
+ System.clearProperty("TRUSTIFY_DA_WORKSPACE_DIR");
+ }
+ }
+
+ /** Walk-up stops at uv workspace root boundary without lock file — falls back to pip. */
+ @Test
+ void testStopsAtUvWorkspaceRootBoundary(@TempDir Path tempDir) throws IOException {
+ // Workspace root with uv workspace config but NO lock file
+ Files.writeString(
+ tempDir.resolve("pyproject.toml"),
+ "[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n"
+ + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
+
+ // Member package
+ Path memberDir = tempDir.resolve("packages/lib");
+ Files.createDirectories(memberDir);
+ Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
+
+ // Should fall back to pip, not keep walking up
+ var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml"));
+ assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
+ }
+
+ /** When manifestDir itself is a workspace root without uv.lock, don't walk up to parent. */
+ @Test
+ void testStartDirIsWorkspaceRootDoesNotWalkUp(@TempDir Path tempDir) throws IOException {
+ // Parent has uv.lock (unrelated workspace)
+ Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
+ Files.writeString(
+ tempDir.resolve("pyproject.toml"),
+ "[project]\nname = \"parent-ws\"\nversion = \"1.0.0\"\n\n"
+ + "[tool.uv.workspace]\nmembers = [\"child-ws\"]\n");
+
+ // Child is itself a workspace root, but has no uv.lock
+ Path childWs = tempDir.resolve("child-ws");
+ Files.createDirectories(childWs);
+ Files.writeString(
+ childWs.resolve("pyproject.toml"),
+ "[project]\nname = \"child-ws\"\nversion = \"1.0.0\"\n\n"
+ + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
+
+ // Should NOT pick up the parent's uv.lock — should fall back to pip
+ var provider = PythonProviderFactory.create(childWs.resolve("pyproject.toml"));
+ assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
+ }
+
+ /** validateLockFile passes for workspace member when uv.lock is in parent directory. */
+ @Test
+ void testValidateLockFilePassesWithLockInParent(@TempDir Path tempDir) throws IOException {
+ // Workspace root with uv.lock
+ Files.writeString(
+ tempDir.resolve("pyproject.toml"),
+ "[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n"
+ + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
+ Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
+
+ // Member package without its own lock file
+ Path memberDir = tempDir.resolve("packages/my-lib");
+ Files.createDirectories(memberDir);
+ Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
+
+ var provider = new PythonUvProvider(memberDir.resolve("pyproject.toml"));
+ // Ecosystem.getProvider() calls validateLockFile(manifestPath.getParent())
+ assertThatCode(() -> provider.validateLockFile(memberDir)).doesNotThrowAnyException();
+ }
+
+ /** validateLockFile throws when uv.lock is not found anywhere. */
+ @Test
+ void testValidateLockFileThrowsWhenNotFound(@TempDir Path tempDir) throws IOException {
+ Path memberDir = tempDir.resolve("packages/my-lib");
+ Files.createDirectories(memberDir);
+ Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
+
+ var provider = new PythonUvProvider(memberDir.resolve("pyproject.toml"));
+ assertThatThrownBy(() -> provider.validateLockFile(memberDir))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("uv.lock does not exist");
+ }
+}
diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java
index 51cce7b8..00216395 100644
--- a/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java
+++ b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java
@@ -22,6 +22,7 @@
import io.github.guacsec.trustifyda.Api;
import io.github.guacsec.trustifyda.ExhortTest;
import io.github.guacsec.trustifyda.tools.Ecosystem;
+import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -247,12 +248,11 @@ void test_extractDepName() {
@Test
void test_canonicalize() {
- assertThat(PythonPyprojectProvider.canonicalize("charset_normalizer"))
+ assertThat(PyprojectTomlUtils.canonicalize("charset_normalizer"))
.isEqualTo("charset-normalizer");
- assertThat(PythonPyprojectProvider.canonicalize("Jinja2")).isEqualTo("jinja2");
- assertThat(PythonPyprojectProvider.canonicalize("MarkupSafe")).isEqualTo("markupsafe");
- assertThat(PythonPyprojectProvider.canonicalize("my.package_name"))
- .isEqualTo("my-package-name");
+ assertThat(PyprojectTomlUtils.canonicalize("Jinja2")).isEqualTo("jinja2");
+ assertThat(PyprojectTomlUtils.canonicalize("MarkupSafe")).isEqualTo("markupsafe");
+ assertThat(PyprojectTomlUtils.canonicalize("my.package_name")).isEqualTo("my-package-name");
}
@Test
diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java
new file mode 100644
index 00000000..7fda0b84
--- /dev/null
+++ b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java
@@ -0,0 +1,415 @@
+/*
+ * Copyright 2023-2025 Trustify Dependency Analytics Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.guacsec.trustifyda.providers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.github.guacsec.trustifyda.Api;
+import io.github.guacsec.trustifyda.ExhortTest;
+import io.github.guacsec.trustifyda.tools.Ecosystem;
+import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class Python_Uv_Provider_Test extends ExhortTest {
+
+ private static final String UV_FIXTURE =
+ "src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv";
+ private static final String UV_IGNORE_FIXTURE =
+ "src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore";
+
+ @Test
+ void test_ecosystem_resolves_pyproject_toml_with_uv_lock() throws IOException {
+ var tempDir =
+ new TempDirFromResources()
+ .addFile("pyproject.toml")
+ .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml")
+ .addFile("uv.lock")
+ .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/uv.lock");
+ Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml");
+ var provider = Ecosystem.getProvider(pyprojectPath);
+ assertThat(provider).isInstanceOf(PythonUvProvider.class);
+ assertThat(provider.ecosystem).isEqualTo(Ecosystem.Type.PYTHON);
+ }
+
+ @Test
+ void test_ecosystem_resolves_pyproject_toml_without_uv_lock() {
+ var provider =
+ Ecosystem.getProvider(
+ Path.of(
+ "src/test/resources/tst_manifests/pip/pip_pyproject_toml_no_ignore/pyproject.toml"));
+ assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
+ }
+
+ @Test
+ void test_factory_selects_uv_provider_with_lock() throws IOException {
+ var tempDir =
+ new TempDirFromResources()
+ .addFile("pyproject.toml")
+ .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml")
+ .addFile("uv.lock")
+ .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/uv.lock");
+ Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml");
+ var provider = PythonProviderFactory.create(pyprojectPath);
+ assertThat(provider).isInstanceOf(PythonUvProvider.class);
+ }
+
+ @Test
+ void test_factory_falls_back_to_pyproject_without_lock() {
+ Path pyprojectPath =
+ Path.of("src/test/resources/tst_manifests/pip/pip_pyproject_toml_no_ignore/pyproject.toml");
+ var provider = PythonProviderFactory.create(pyprojectPath);
+ assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
+ }
+
+ @Test
+ void test_validate_lock_file_passes_with_uv_lock() throws IOException {
+ var tempDir =
+ new TempDirFromResources()
+ .addFile("pyproject.toml")
+ .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml")
+ .addFile("uv.lock")
+ .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/uv.lock");
+ Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+ provider.validateLockFile(tempDir.getTempDir());
+ }
+
+ @Test
+ void test_validate_lock_file_throws_without_uv_lock() throws IOException {
+ var tempDir =
+ new TempDirFromResources()
+ .addFile("pyproject.toml")
+ .fromResources("tst_manifests/pip/pip_pyproject_toml_no_ignore/pyproject.toml");
+ Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+ assertThatIllegalStateException()
+ .isThrownBy(() -> provider.validateLockFile(tempDir.getTempDir()))
+ .withMessageContaining("uv.lock does not exist");
+ }
+
+ @Test
+ void test_parseUvExport_parses_packages() throws IOException {
+ Path exportPath = Path.of(UV_FIXTURE, "uv_export.txt");
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+ String exportOutput = Files.readString(exportPath);
+ var data = provider.parseUvExport(exportOutput);
+ assertThat(data.graph()).containsKeys("anyio", "flask", "requests", "idna", "sniffio");
+ assertThat(data.graph().get("anyio").version()).isEqualTo("3.6.2");
+ assertThat(data.graph().get("flask").version()).isEqualTo("2.0.3");
+ }
+
+ @Test
+ void test_parseUvExport_builds_children() throws IOException {
+ Path exportPath = Path.of(UV_FIXTURE, "uv_export.txt");
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+ String exportOutput = Files.readString(exportPath);
+ var data = provider.parseUvExport(exportOutput);
+
+ var requestsPkg = data.graph().get("requests");
+ assertThat(requestsPkg).isNotNull();
+ assertThat(requestsPkg.children())
+ .containsExactlyInAnyOrder("charset-normalizer", "idna", "urllib3", "certifi");
+
+ var anyioPkg = data.graph().get("anyio");
+ assertThat(anyioPkg).isNotNull();
+ assertThat(anyioPkg.children()).containsExactlyInAnyOrder("idna", "sniffio");
+
+ var flaskPkg = data.graph().get("flask");
+ assertThat(flaskPkg).isNotNull();
+ assertThat(flaskPkg.children())
+ .containsExactlyInAnyOrder("werkzeug", "jinja2", "itsdangerous", "click");
+
+ var jinja2Pkg = data.graph().get("jinja2");
+ assertThat(jinja2Pkg).isNotNull();
+ assertThat(jinja2Pkg.children()).containsExactly("markupsafe");
+ }
+
+ @Test
+ void test_parseUvExport_identifies_direct_deps() throws IOException {
+ Path exportPath = Path.of(UV_FIXTURE, "uv_export.txt");
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+ String exportOutput = Files.readString(exportPath);
+ var data = provider.parseUvExport(exportOutput);
+ assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "flask", "requests");
+ }
+
+ @Test
+ void test_getRootComponentName_reads_pep621_name() {
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+ assertThat(provider.getRootComponentName()).isEqualTo("test-project");
+ }
+
+ @Test
+ void test_getRootComponentVersion_reads_pep621_version() {
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+ assertThat(provider.getRootComponentVersion()).isEqualTo("0.1.0");
+ }
+
+ @Test
+ void test_provideStack_with_uv_export() throws IOException {
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export.txt"));
+
+ System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
+ try {
+ var provider = new PythonUvProvider(pyprojectPath);
+ var content = provider.provideStack();
+ assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
+ String sbomJson = new String(content.buffer);
+ assertThat(sbomJson).contains("CycloneDX");
+ assertThat(sbomJson).contains("pkg:pypi/");
+ assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
+ assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
+ assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
+ assertThat(sbomJson).contains("pkg:pypi/idna@3.4");
+ assertThat(sbomJson).contains("pkg:pypi/sniffio@1.3.0");
+ assertThat(sbomJson).contains("pkg:pypi/certifi@2023.5.7");
+ assertThat(sbomJson).contains("pkg:pypi/markupsafe@2.1.2");
+ } catch (RuntimeException | NoClassDefFoundError e) {
+ Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
+ } finally {
+ System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
+ }
+ }
+
+ @Test
+ void test_provideComponent_with_uv_export() throws IOException {
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export.txt"));
+
+ System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
+ try {
+ var provider = new PythonUvProvider(pyprojectPath);
+ var content = provider.provideComponent();
+ assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
+ String sbomJson = new String(content.buffer);
+ assertThat(sbomJson).contains("CycloneDX");
+ assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
+ assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
+ assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
+ } catch (RuntimeException | NoClassDefFoundError e) {
+ Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
+ } finally {
+ System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
+ }
+ }
+
+ @Test
+ void test_ignored_dependencies_in_uv_project() throws IOException {
+ Path pyprojectPath = Path.of(UV_IGNORE_FIXTURE, "pyproject.toml");
+ String exportOutput = Files.readString(Path.of(UV_IGNORE_FIXTURE, "uv_export.txt"));
+
+ System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
+ try {
+ var provider = new PythonUvProvider(pyprojectPath);
+ var content = provider.provideStack();
+ String sbomJson = new String(content.buffer);
+ assertThat(sbomJson).doesNotContain("pkg:pypi/flask@");
+ assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
+ assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
+ } catch (RuntimeException | NoClassDefFoundError e) {
+ Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
+ } finally {
+ System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
+ }
+ }
+
+ @Test
+ void test_parseUvExport_includes_editable_installs(@TempDir Path tempDir) throws IOException {
+ // Create a workspace member with its own pyproject.toml
+ Path memberDir = tempDir.resolve("packages").resolve("my-lib");
+ Files.createDirectories(memberDir);
+ Files.writeString(
+ memberDir.resolve("pyproject.toml"),
+ "[project]\nname = \"my-lib\"\nversion = \"2.0.0\"\ndependencies = []\n");
+
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+
+ // Simulate uv export output with an editable install
+ String exportOutput =
+ "# This file was autogenerated by uv\n"
+ + "-e file://"
+ + memberDir.toUri().getPath()
+ + "\n"
+ + " # via test-project\n"
+ + "anyio==3.6.2\n"
+ + " # via my-lib\n";
+
+ var data = provider.parseUvExport(exportOutput);
+
+ // The editable install should be in the graph with name/version from pyproject.toml
+ assertThat(data.graph()).containsKey("my-lib");
+ assertThat(data.graph().get("my-lib").name()).isEqualTo("my-lib");
+ assertThat(data.graph().get("my-lib").version()).isEqualTo("2.0.0");
+
+ // It should be identified as a direct dependency (via test-project)
+ assertThat(data.directDeps()).contains("my-lib");
+
+ // anyio should be a child of my-lib
+ assertThat(data.graph().get("my-lib").children()).contains("anyio");
+ }
+
+ @Test
+ void test_parseUvExport_editable_skips_self_reference(@TempDir Path tempDir) throws IOException {
+ // Create a member whose name matches the root project (test-project)
+ Path memberDir = tempDir.resolve("packages").resolve("self");
+ Files.createDirectories(memberDir);
+ Files.writeString(
+ memberDir.resolve("pyproject.toml"),
+ "[project]\nname = \"test-project\"\nversion = \"0.1.0\"\ndependencies = []\n");
+
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+
+ String exportOutput =
+ "# This file was autogenerated by uv\n"
+ + "-e file://"
+ + memberDir.toUri().getPath()
+ + "\n"
+ + "anyio==3.6.2\n"
+ + " # via test-project\n";
+
+ var data = provider.parseUvExport(exportOutput);
+
+ // The root project should NOT appear in the graph as its own dependency
+ assertThat(data.graph()).doesNotContainKey("test-project");
+ // anyio should still be a direct dep
+ assertThat(data.directDeps()).contains("anyio");
+ }
+
+ @Test
+ void test_parseUvExport_editable_skips_missing_version(@TempDir Path tempDir) throws IOException {
+ // Create a member with no version
+ Path memberDir = tempDir.resolve("packages").resolve("no-version");
+ Files.createDirectories(memberDir);
+ Files.writeString(
+ memberDir.resolve("pyproject.toml"),
+ "[project]\nname = \"no-version-lib\"\ndependencies = []\n");
+
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+
+ String exportOutput =
+ "# This file was autogenerated by uv\n"
+ + "-e file://"
+ + memberDir.toUri().getPath()
+ + "\n"
+ + " # via test-project\n"
+ + "anyio==3.6.2\n"
+ + " # via test-project\n";
+
+ var data = provider.parseUvExport(exportOutput);
+
+ // Package with no version should be skipped
+ assertThat(data.graph()).doesNotContainKey("no-version-lib");
+ // anyio should still be parsed
+ assertThat(data.graph()).containsKey("anyio");
+ }
+
+ @Test
+ void test_parseUvExport_editable_falls_back_to_poetry_name(@TempDir Path tempDir)
+ throws IOException {
+ // Create a member with Poetry-style name/version only
+ Path memberDir = tempDir.resolve("packages").resolve("poetry-lib");
+ Files.createDirectories(memberDir);
+ Files.writeString(
+ memberDir.resolve("pyproject.toml"),
+ "[tool.poetry]\nname = \"poetry-lib\"\nversion = \"1.5.0\"\n");
+
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+
+ String exportOutput =
+ "# This file was autogenerated by uv\n"
+ + "-e file://"
+ + memberDir.toUri().getPath()
+ + "\n"
+ + " # via test-project\n"
+ + "anyio==3.6.2\n"
+ + " # via poetry-lib\n";
+
+ var data = provider.parseUvExport(exportOutput);
+
+ // Poetry name/version should be used as fallback
+ assertThat(data.graph()).containsKey("poetry-lib");
+ assertThat(data.graph().get("poetry-lib").name()).isEqualTo("poetry-lib");
+ assertThat(data.graph().get("poetry-lib").version()).isEqualTo("1.5.0");
+
+ // It should be a direct dep and anyio should be its child
+ assertThat(data.directDeps()).contains("poetry-lib");
+ assertThat(data.graph().get("poetry-lib").children()).contains("anyio");
+ }
+
+ @Test
+ void test_parseUvExport_via_skips_non_bare_package_names() throws IOException {
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+
+ String exportOutput =
+ "# This file was autogenerated by uv\n"
+ + "anyio==3.6.2\n"
+ + " # via test-project\n"
+ + "idna==3.4\n"
+ + " # via foo (>=1.0)\n"
+ + "sniffio==1.3.0\n"
+ + " # via foo[extra]\n";
+
+ var data = provider.parseUvExport(exportOutput);
+
+ // anyio is direct (via test-project)
+ assertThat(data.directDeps()).contains("anyio");
+ // idna and sniffio should be in the graph but with no parent edges
+ assertThat(data.graph()).containsKey("idna");
+ assertThat(data.graph()).containsKey("sniffio");
+ // No package should have children since the via names were invalid
+ assertThat(data.graph().values().stream().allMatch(p -> p.children().isEmpty())).isTrue();
+ }
+
+ @Test
+ void test_parseUvExport_throws_on_unpinned_version() {
+ Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
+ var provider = new PythonUvProvider(pyprojectPath);
+
+ String exportOutput =
+ "# This file was autogenerated by uv\n" + "anyio>=3.6.2\n" + " # via test-project\n";
+
+ assertThatThrownBy(() -> provider.parseUvExport(exportOutput))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("has no pinned version");
+ }
+
+ @Test
+ void test_canonicalize() {
+ assertThat(PyprojectTomlUtils.canonicalize("charset_normalizer"))
+ .isEqualTo("charset-normalizer");
+ assertThat(PyprojectTomlUtils.canonicalize("Jinja2")).isEqualTo("jinja2");
+ assertThat(PyprojectTomlUtils.canonicalize("MarkupSafe")).isEqualTo("markupsafe");
+ }
+}
diff --git a/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java b/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java
index 2b382110..ee63d5d1 100644
--- a/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java
+++ b/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java
@@ -301,6 +301,18 @@ void get_Dependency_Name_with_markers() {
PythonControllerRealEnv.getDependencyName("certifi==2023.7.22 ; python_version >= \"3\""));
}
+ /** Verifies getDependencyName strips PEP 508 extras from requirements. */
+ @Test
+ void get_Dependency_Name_with_extras() {
+ assertEquals("requests", PythonControllerRealEnv.getDependencyName("requests[security]>=2.0"));
+ assertEquals("requests", PythonControllerRealEnv.getDependencyName("requests[security]"));
+ assertEquals("uvicorn", PythonControllerRealEnv.getDependencyName("uvicorn[standard]>=0.15.0"));
+ assertEquals(
+ "package",
+ PythonControllerRealEnv.getDependencyName(
+ "package[extra1]>=1.0 ; python_version >= \"3.8\""));
+ }
+
@Test
void automaticallyInstallPackageOnEnvironment() {
assertFalse(pythonControllerRealEnv.automaticallyInstallPackageOnEnvironment());
diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml
new file mode 100644
index 00000000..3f875d01
--- /dev/null
+++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml
@@ -0,0 +1,18 @@
+[project]
+name = "test-project"
+version = "0.1.0"
+dependencies = [
+ "anyio==3.6.2",
+ "flask==2.0.3",
+ "requests==2.25.1",
+]
+
+[project.optional-dependencies]
+dev = [
+ "click==8.0.4",
+]
+
+[tool.uv]
+dev-dependencies = [
+ "pytest>=7.0",
+]
diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv.lock b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv.lock
new file mode 100644
index 00000000..b0dd0b31
--- /dev/null
+++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv.lock
@@ -0,0 +1,96 @@
+version = 1
+requires-python = ">=3.9"
+
+[[package]]
+name = "test-project"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "anyio" },
+ { name = "flask" },
+ { name = "requests" },
+]
+
+[[package]]
+name = "anyio"
+version = "3.6.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "sniffio" },
+]
+
+[[package]]
+name = "flask"
+version = "2.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "werkzeug" },
+ { name = "jinja2" },
+ { name = "itsdangerous" },
+ { name = "click" },
+]
+
+[[package]]
+name = "requests"
+version = "2.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+ { name = "certifi" },
+]
+
+[[package]]
+name = "idna"
+version = "3.4"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "sniffio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "werkzeug"
+version = "2.2.3"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+
+[[package]]
+name = "itsdangerous"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "click"
+version = "8.1.3"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "charset-normalizer"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "urllib3"
+version = "1.26.15"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "certifi"
+version = "2023.5.7"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "markupsafe"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_export.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_export.txt
new file mode 100644
index 00000000..30125dd0
--- /dev/null
+++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_export.txt
@@ -0,0 +1,30 @@
+# This file was autogenerated by uv via the following command:
+# uv export --format requirements.txt --frozen --no-hashes --no-dev
+anyio==3.6.2
+ # via test-project
+certifi==2023.5.7
+ # via requests
+charset-normalizer==3.1.0
+ # via requests
+click==8.1.3
+ # via flask
+flask==2.0.3
+ # via test-project
+idna==3.4
+ # via
+ # anyio
+ # requests
+itsdangerous==2.1.2
+ # via flask
+jinja2==3.1.2
+ # via flask
+markupsafe==2.1.2
+ # via jinja2
+requests==2.25.1
+ # via test-project
+sniffio==1.3.0
+ # via anyio
+urllib3==1.26.15
+ # via requests
+werkzeug==2.2.3
+ # via flask
diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/pyproject.toml b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/pyproject.toml
new file mode 100644
index 00000000..30c9d2be
--- /dev/null
+++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/pyproject.toml
@@ -0,0 +1,18 @@
+[project]
+name = "test-project"
+version = "0.1.0"
+dependencies = [
+ "anyio==3.6.2",
+ "flask==2.0.3", # exhortignore
+ "requests==2.25.1",
+]
+
+[project.optional-dependencies]
+dev = [
+ "click==8.0.4",
+]
+
+[tool.uv]
+dev-dependencies = [
+ "pytest>=7.0",
+]
diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv.lock b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv.lock
new file mode 100644
index 00000000..b0dd0b31
--- /dev/null
+++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv.lock
@@ -0,0 +1,96 @@
+version = 1
+requires-python = ">=3.9"
+
+[[package]]
+name = "test-project"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "anyio" },
+ { name = "flask" },
+ { name = "requests" },
+]
+
+[[package]]
+name = "anyio"
+version = "3.6.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "sniffio" },
+]
+
+[[package]]
+name = "flask"
+version = "2.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "werkzeug" },
+ { name = "jinja2" },
+ { name = "itsdangerous" },
+ { name = "click" },
+]
+
+[[package]]
+name = "requests"
+version = "2.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+ { name = "certifi" },
+]
+
+[[package]]
+name = "idna"
+version = "3.4"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "sniffio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "werkzeug"
+version = "2.2.3"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+
+[[package]]
+name = "itsdangerous"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "click"
+version = "8.1.3"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "charset-normalizer"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "urllib3"
+version = "1.26.15"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "certifi"
+version = "2023.5.7"
+source = { registry = "https://pypi.org/simple" }
+
+[[package]]
+name = "markupsafe"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_export.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_export.txt
new file mode 100644
index 00000000..30125dd0
--- /dev/null
+++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_export.txt
@@ -0,0 +1,30 @@
+# This file was autogenerated by uv via the following command:
+# uv export --format requirements.txt --frozen --no-hashes --no-dev
+anyio==3.6.2
+ # via test-project
+certifi==2023.5.7
+ # via requests
+charset-normalizer==3.1.0
+ # via requests
+click==8.1.3
+ # via flask
+flask==2.0.3
+ # via test-project
+idna==3.4
+ # via
+ # anyio
+ # requests
+itsdangerous==2.1.2
+ # via flask
+jinja2==3.1.2
+ # via flask
+markupsafe==2.1.2
+ # via jinja2
+requests==2.25.1
+ # via test-project
+sniffio==1.3.0
+ # via anyio
+urllib3==1.26.15
+ # via requests
+werkzeug==2.2.3
+ # via flask