From b7611e00cda44672afeb87e7d6fad6d0cd318416 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 20 Apr 2026 15:04:15 +0300 Subject: [PATCH 1/6] feat(python): add PythonUvProvider and PythonProviderFactory for uv package manager support TC-3855 Add automatic detection and support for Python projects managed by the uv package manager. When uv.lock is present alongside pyproject.toml, the new PythonProviderFactory selects PythonUvProvider (using uv pip list/show for dependency resolution) instead of the pip-based PythonPyprojectProvider. - Add PythonProviderFactory with Map-based lock file detection pattern - Add PythonUvProvider with CycloneDX SBOM generation (stack + component) - Add PyprojectTomlUtils shared utility to deduplicate TOML parsing logic - Refactor PythonPyprojectProvider to use PyprojectTomlUtils - Fix PEP 508 extras stripping in getDependencyName (e.g. requests[security]) - Update Ecosystem.resolveProvider() to delegate to PythonProviderFactory - Add test fixtures and 15 unit tests for the uv provider - Document uv support, TRUSTIFY_DA_UV_PATH, and CLI examples in README Co-Authored-By: Claude Opus 4.6 Assisted-by: Claude Code --- README.md | 22 +- .../providers/PythonProviderFactory.java | 53 +++ .../providers/PythonPyprojectProvider.java | 56 +-- .../providers/PythonUvProvider.java | 408 ++++++++++++++++++ .../guacsec/trustifyda/tools/Ecosystem.java | 4 +- .../trustifyda/utils/PyprojectTomlUtils.java | 107 +++++ .../utils/PythonControllerBase.java | 8 + .../providers/Python_Uv_Provider_Test.java | 263 +++++++++++ .../utils/PythonControllerRealEnvTest.java | 12 + .../pip/pip_pyproject_toml_uv/pyproject.toml | 18 + .../pip/pip_pyproject_toml_uv/uv.lock | 96 +++++ .../pip_pyproject_toml_uv/uv_pip_list.json | 15 + .../pip/pip_pyproject_toml_uv/uv_pip_show.txt | 77 ++++ .../pyproject.toml | 18 + .../pip/pip_pyproject_toml_uv_ignore/uv.lock | 96 +++++ .../uv_pip_list.json | 15 + .../uv_pip_show.txt | 77 ++++ 17 files changed, 1296 insertions(+), 49 deletions(-) create mode 100644 src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java create mode 100644 src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java create mode 100644 src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java create mode 100644 src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv.lock create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_list.json create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/pyproject.toml create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv.lock create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_list.json create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt diff --git a/README.md b/README.md index 9e8eb998..0dce42c4 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ public class TrustifyExample {
  • Java - Maven
  • JavaScript - Npm
  • Golang - Go Modules
  • -
  • Python - pip Installer (requirements.txt, pyproject.toml with PEP 621 format). Note: Poetry-style dependencies ([tool.poetry.dependencies]) are not supported.
  • +
  • Python - pip Installer / uv (requirements.txt, pyproject.toml with PEP 621 format). Note: Poetry-style dependencies ([tool.poetry.dependencies]) are not supported.
  • Gradle - Gradle Installation
  • Rust - Cargo
  • @@ -393,6 +393,7 @@ System.setProperty("TRUSTIFY_DA_PYTHON3_PATH", "/path/to/python3"); System.setProperty("TRUSTIFY_DA_PIP3_PATH", "/path/to/pip3"); System.setProperty("TRUSTIFY_DA_PYTHON_PATH", "/path/to/python"); System.setProperty("TRUSTIFY_DA_PIP_PATH", "/path/to/pip"); +System.setProperty("TRUSTIFY_DA_UV_PATH", "/path/to/custom/uv"); // Configure proxy for all requests System.setProperty("TRUSTIFY_DA_PROXY_URL", "http://proxy.example.com:8080"); // Configure Maven settings and repository @@ -504,6 +505,11 @@ following keys for setting custom paths for the said executables. TRUSTIFY_DA_PIP_PATH +uv Package Manager +uv +TRUSTIFY_DA_UV_PATH + + Cargo Package Manager cargo TRUSTIFY_DA_CARGO_PATH @@ -600,6 +606,14 @@ TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED=false Python support works with both `requirements.txt` and `pyproject.toml` manifest files. The `pyproject.toml` provider scans production dependencies from PEP 621 `[project.dependencies]` and Poetry `[tool.poetry.dependencies]` sections. Optional dependencies (`[project.optional-dependencies]`) and Poetry group dependencies (`[tool.poetry.group.*.dependencies]`) are intentionally excluded to focus on runtime dependencies. +##### uv Support + +When a `uv.lock` file is present alongside `pyproject.toml`, the client automatically uses the [uv](https://docs.astral.sh/uv/) package manager for dependency resolution instead of pip. Dependency data is collected via `uv pip list --format=json` and `uv pip show`. No additional configuration is required — the provider is selected automatically based on lock file detection. + +To use a custom uv binary, set `TRUSTIFY_DA_UV_PATH` to the path of your uv executable. + +##### pip Support + By default, Python support assumes that the package is installed using the pip/pip3 binary on the system PATH, or of the customized Binaries passed to environment variables. If the package is not installed , then an error will be thrown. @@ -683,6 +697,12 @@ Options: - `--output ` - Write SBOM JSON to the specified file - (default) - Print SBOM JSON to stdout +**SBOM for uv-managed Python project** +```shell +# When uv.lock is present alongside pyproject.toml, the uv provider is used automatically +java -jar trustify-da-java-client-cli.jar sbom /path/to/uv-project/pyproject.toml +``` + **Image Analysis** ```shell java -jar trustify-da-java-client-cli.jar image [...] [--summary|--html] diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java new file mode 100644 index 00000000..a890c44d --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java @@ -0,0 +1,53 @@ +/* + * 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 java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Function; + +/** + * Factory for creating the appropriate {@link PythonProvider} based on lock file presence. Follows + * the same pattern as {@link JavaScriptProviderFactory}. + */ +public final class PythonProviderFactory { + + private static final Map> PYTHON_PROVIDERS = + Map.of(PythonUvProvider.LOCK_FILE, PythonUvProvider::new); + + /** + * Creates a Python provider for {@code pyproject.toml} manifests by checking for known lock files + * in the manifest directory. When {@code uv.lock} is present, returns a {@link PythonUvProvider}; + * otherwise falls back to {@link PythonPyprojectProvider} (pip-based). + * + * @param manifestPath the path to the pyproject.toml manifest + * @return the matching Python provider + */ + public static PythonProvider create(final Path manifestPath) { + var manifestDir = manifestPath.getParent(); + + for (var entry : PYTHON_PROVIDERS.entrySet()) { + if (Files.isRegularFile(manifestDir.resolve(entry.getKey()))) { + return entry.getValue().apply(manifestPath); + } + } + + // Unlike JavaScript, pip fallback is valid — no lock file required + return new PythonPyprojectProvider(manifestPath); + } +} diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java index fd8d2cba..10d59416 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java @@ -25,6 +25,7 @@ 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.nio.charset.StandardCharsets; @@ -42,10 +43,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.tomlj.Toml; import org.tomlj.TomlArray; import org.tomlj.TomlParseResult; -import org.tomlj.TomlTable; /** * Provider for Python projects using {@code pyproject.toml} with getIgnoredDependencies(String manifestContent) { } private void rejectPoetryDependencies() throws IOException { - TomlParseResult toml = getToml(); - TomlTable poetryDeps = toml.getTable("tool.poetry.dependencies"); - if (poetryDeps != null) { + if (PyprojectTomlUtils.hasPoetryDependencies(getToml())) { throw new IllegalStateException( "Poetry dependencies in pyproject.toml are not supported." + " Please use PEP 621 [project.dependencies] format instead."); @@ -424,18 +408,7 @@ private void rejectPoetryDependencies() throws IOException { } private void collectIgnoredDeps() throws IOException { - TomlParseResult toml = getToml(); - List rawLines = Files.readAllLines(manifest); - collectedIgnoredDeps = new HashSet<>(); - - // [project.dependencies] - PEP 621 - TomlArray projectDeps = toml.getArray("project.dependencies"); - if (projectDeps != null) { - for (int i = 0; i < projectDeps.size(); i++) { - String dep = projectDeps.getString(i); - checkIgnored(rawLines, dep, dep); - } - } + collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifest, getToml()); } List parseDependencyStrings() throws IOException { @@ -453,15 +426,6 @@ List parseDependencyStrings() throws IOException { return deps; } - private void checkIgnored(List 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..9f3ef8e8 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java @@ -0,0 +1,408 @@ +/* + * 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.fasterxml.jackson.databind.JsonNode; +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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +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.stream.Collectors; +import org.tomlj.TomlArray; +import org.tomlj.TomlParseResult; + +/** + * Provider for Python projects using {@code pyproject.toml} with the uv package manager. + * + *

    Dependency resolution is performed via {@code uv pip list --format=json} and {@code uv pip + * show}, which are pip-compatible output formats. 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_PIP_LIST = "TRUSTIFY_DA_UV_PIP_LIST"; + static final String PROP_TRUSTIFY_DA_UV_PIP_SHOW = "TRUSTIFY_DA_UV_PIP_SHOW"; + + 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) { + if (!Files.isRegularFile(lockFileDir.resolve(LOCK_FILE))) { + 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 = manifest.toAbsolutePath().getParent(); + String listJson = getUvPipListOutput(manifestDir); + UvDependencyData data = buildDependencyGraph(manifestDir, listJson); + + 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(manifest); + 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 = manifest.toAbsolutePath().getParent(); + String listJson = getUvPipListOutput(manifestDir); + Map packages = parseUvPipList(listJson); + + Sbom sbom = SbomFactory.newInstance(); + sbom.addRoot( + toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest()); + + List directDeps = getDirectDependencyNames(); + for (String depName : directDeps) { + String key = canonicalize(depName); + UvPackage pkg = packages.get(key); + if (pkg != null) { + sbom.addDependency(sbom.getRoot(), toPurl(pkg.name, pkg.version), null); + } + } + + String manifestContent = Files.readString(manifest); + 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 = 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); + } + } + } + + /** + * Builds the full dependency graph by combining {@code uv pip list --format=json} (all packages + * with versions) and {@code uv pip show} (dependency relationships via Requires field). + */ + UvDependencyData buildDependencyGraph(Path manifestDir, String listJson) throws IOException { + Map packages = parseUvPipList(listJson); + + // Get dependency relationships via uv pip show + if (!packages.isEmpty()) { + List packageNames = + packages.values().stream().map(pkg -> pkg.name).collect(Collectors.toList()); + String showOutput = getUvPipShowOutput(manifestDir, packageNames); + parseUvPipShow(showOutput, packages); + } + + List directDeps = + getDirectDependencyNames().stream() + .map(PythonUvProvider::canonicalize) + .filter(packages::containsKey) + .collect(Collectors.toList()); + + return new UvDependencyData(directDeps, packages); + } + + /** + * Parses the JSON output of {@code uv pip list --format=json}. The output is a JSON array of + * objects with {@code name} and {@code version} fields: + * + *

    +   * [{"name": "anyio", "version": "3.6.2"}, ...]
    +   * 
    + */ + Map parseUvPipList(String listJson) throws IOException { + JsonNode listArray = objectMapper.readTree(listJson); + Map packages = new HashMap<>(); + if (listArray == null || !listArray.isArray()) { + return packages; + } + for (JsonNode entry : listArray) { + String name = entry.has("name") ? entry.get("name").asText() : null; + String version = entry.has("version") ? entry.get("version").asText() : null; + if (name != null) { + String key = canonicalize(name); + packages.put(key, new UvPackage(name, version, new ArrayList<>())); + } + } + return packages; + } + + /** + * Parses the text output of {@code uv pip show} to extract dependency relationships. Each package + * block is separated by {@code ---} and contains a {@code Requires:} field listing dependencies. + */ + void parseUvPipShow(String showOutput, Map packages) { + List blocks = splitPipShowBlocks(showOutput); + for (String block : blocks) { + String name = extractShowField(block, "Name:"); + if (name == null) { + continue; + } + String key = canonicalize(name); + UvPackage pkg = packages.get(key); + if (pkg == null) { + continue; + } + String requires = extractShowField(block, "Requires:"); + if (requires != null && !requires.isBlank()) { + Arrays.stream(requires.split(",")) + .map(String::trim) + .filter(dep -> !dep.isEmpty()) + .forEach( + dep -> { + String depKey = canonicalize(dep); + if (packages.containsKey(depKey)) { + pkg.children.add(depKey); + } + }); + } + } + } + + private List splitPipShowBlocks(String showOutput) { + return Arrays.stream(showOutput.split("\\r?\\n---\\r?\\n")) + .filter(block -> !block.isBlank()) + .collect(Collectors.toList()); + } + + private String extractShowField(String block, String fieldName) { + int fieldIndex = block.indexOf(fieldName); + if (fieldIndex == -1) { + return null; + } + String afterField = block.substring(fieldIndex + fieldName.length()); + int endOfLine = afterField.indexOf('\n'); + if (endOfLine == -1) { + return afterField.trim(); + } + return afterField.substring(0, endOfLine).trim(); + } + + String getUvPipListOutput(Path manifestDir) { + String envValue = Environment.get(PROP_TRUSTIFY_DA_UV_PIP_LIST); + if (envValue != null && !envValue.isBlank()) { + return envValue; + } + + String[] cmd = {uvExecutable, "pip", "list", "--format=json"}; + Operations.ProcessExecOutput result = + Operations.runProcessGetFullOutput(manifestDir, cmd, null); + if (result.getExitCode() != 0) { + throw new RuntimeException( + String.format( + "uv pip list command failed with exit code %d: %s", + result.getExitCode(), result.getError())); + } + return result.getOutput(); + } + + String getUvPipShowOutput(Path manifestDir, List packageNames) { + String envValue = Environment.get(PROP_TRUSTIFY_DA_UV_PIP_SHOW); + if (envValue != null && !envValue.isBlank()) { + return envValue; + } + + List cmdParts = new ArrayList<>(); + cmdParts.add(uvExecutable); + cmdParts.add("pip"); + cmdParts.add("show"); + cmdParts.addAll(packageNames); + + String[] cmd = cmdParts.toArray(new String[0]); + Operations.ProcessExecOutput result = + Operations.runProcessGetFullOutput(manifestDir, cmd, null); + if (result.getExitCode() != 0) { + throw new RuntimeException( + String.format( + "uv pip show command failed with exit code %d: %s", + result.getExitCode(), result.getError())); + } + return result.getOutput(); + } + + private List getDirectDependencyNames() throws IOException { + TomlParseResult toml = getToml(); + List deps = new ArrayList<>(); + TomlArray projectDeps = toml.getArray("project.dependencies"); + if (projectDeps != null) { + for (int i = 0; i < projectDeps.size(); i++) { + String dep = projectDeps.getString(i); + deps.add(PythonControllerBase.getDependencyName(dep)); + } + } + return deps; + } + + static String canonicalize(String name) { + return name.toLowerCase().replaceAll("[-_.]+", "-"); + } + + // --- TOML parsing (shared with PythonPyprojectProvider) --- + + private TomlParseResult getToml() throws IOException { + if (cachedToml == null) { + cachedToml = PyprojectTomlUtils.parseToml(manifest); + } + 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(manifest); + } + + @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(manifest, getToml()); + } + + static final class UvPackage { + final String name; + final String version; + final List children; + + UvPackage(String name, String version, List children) { + this.name = name; + this.version = version; + this.children = children; + } + } + + static final class UvDependencyData { + final List directDeps; + final Map graph; + + UvDependencyData(List directDeps, Map graph) { + this.directDeps = directDeps; + this.graph = 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..78d735af --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java @@ -0,0 +1,107 @@ +/* + * 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; + } +} 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/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..cbebb26b --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java @@ -0,0 +1,263 @@ +/* + * 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 io.github.guacsec.trustifyda.Api; +import io.github.guacsec.trustifyda.ExhortTest; +import io.github.guacsec.trustifyda.tools.Ecosystem; +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; + +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_parseUvPipList_parses_packages() throws IOException { + Path listPath = Path.of(UV_FIXTURE, "uv_pip_list.json"); + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + String listJson = Files.readString(listPath); + var packages = provider.parseUvPipList(listJson); + assertThat(packages).containsKeys("anyio", "flask", "requests", "idna", "sniffio"); + assertThat(packages.get("anyio").version).isEqualTo("3.6.2"); + assertThat(packages.get("flask").version).isEqualTo("2.0.3"); + } + + @Test + void test_parseUvPipShow_builds_children() throws IOException { + Path listPath = Path.of(UV_FIXTURE, "uv_pip_list.json"); + Path showPath = Path.of(UV_FIXTURE, "uv_pip_show.txt"); + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + String listJson = Files.readString(listPath); + String showOutput = Files.readString(showPath); + var packages = provider.parseUvPipList(listJson); + provider.parseUvPipShow(showOutput, packages); + + var requestsPkg = packages.get("requests"); + assertThat(requestsPkg).isNotNull(); + assertThat(requestsPkg.children) + .containsExactlyInAnyOrder("charset-normalizer", "idna", "urllib3", "certifi"); + + var anyioPkg = packages.get("anyio"); + assertThat(anyioPkg).isNotNull(); + assertThat(anyioPkg.children).containsExactlyInAnyOrder("idna", "sniffio"); + + var flaskPkg = packages.get("flask"); + assertThat(flaskPkg).isNotNull(); + assertThat(flaskPkg.children) + .containsExactlyInAnyOrder("werkzeug", "jinja2", "itsdangerous", "click"); + + var jinja2Pkg = packages.get("jinja2"); + assertThat(jinja2Pkg).isNotNull(); + assertThat(jinja2Pkg.children).containsExactly("markupsafe"); + } + + @Test + void test_buildDependencyGraph_identifies_direct_deps() throws IOException { + Path listPath = Path.of(UV_FIXTURE, "uv_pip_list.json"); + Path showPath = Path.of(UV_FIXTURE, "uv_pip_show.txt"); + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + String listJson = Files.readString(listPath); + String showOutput = Files.readString(showPath); + + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW, showOutput); + try { + var data = provider.buildDependencyGraph(Path.of(UV_FIXTURE), listJson); + assertThat(data.directDeps).containsExactlyInAnyOrder("anyio", "flask", "requests"); + } finally { + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW); + } + } + + @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_pip() throws IOException { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + String listJson = Files.readString(Path.of(UV_FIXTURE, "uv_pip_list.json")); + String showOutput = Files.readString(Path.of(UV_FIXTURE, "uv_pip_show.txt")); + + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST, listJson); + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW, showOutput); + 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_PIP_LIST); + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW); + } + } + + @Test + void test_provideComponent_with_uv_pip() throws IOException { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + String listJson = Files.readString(Path.of(UV_FIXTURE, "uv_pip_list.json")); + + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST, listJson); + 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_PIP_LIST); + } + } + + @Test + void test_ignored_dependencies_in_uv_project() throws IOException { + Path pyprojectPath = Path.of(UV_IGNORE_FIXTURE, "pyproject.toml"); + String listJson = Files.readString(Path.of(UV_IGNORE_FIXTURE, "uv_pip_list.json")); + String showOutput = Files.readString(Path.of(UV_IGNORE_FIXTURE, "uv_pip_show.txt")); + + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST, listJson); + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW, showOutput); + 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_PIP_LIST); + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW); + } + } + + @Test + void test_canonicalize() { + assertThat(PythonUvProvider.canonicalize("charset_normalizer")).isEqualTo("charset-normalizer"); + assertThat(PythonUvProvider.canonicalize("Jinja2")).isEqualTo("jinja2"); + assertThat(PythonUvProvider.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_pip_list.json b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_list.json new file mode 100644 index 00000000..05bd336b --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_list.json @@ -0,0 +1,15 @@ +[ + {"name": "anyio", "version": "3.6.2"}, + {"name": "flask", "version": "2.0.3"}, + {"name": "requests", "version": "2.25.1"}, + {"name": "idna", "version": "3.4"}, + {"name": "sniffio", "version": "1.3.0"}, + {"name": "Werkzeug", "version": "2.2.3"}, + {"name": "Jinja2", "version": "3.1.2"}, + {"name": "itsdangerous", "version": "2.1.2"}, + {"name": "click", "version": "8.1.3"}, + {"name": "charset-normalizer", "version": "3.1.0"}, + {"name": "urllib3", "version": "1.26.15"}, + {"name": "certifi", "version": "2023.5.7"}, + {"name": "MarkupSafe", "version": "2.1.2"} +] diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt new file mode 100644 index 00000000..f1d6f86f --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt @@ -0,0 +1,77 @@ +Name: anyio +Version: 3.6.2 +Summary: High level compatibility library for multiple async event loop implementations +Requires: idna, sniffio +Required-by: +--- +Name: flask +Version: 2.0.3 +Summary: A simple framework for building complex web applications. +Requires: Werkzeug, Jinja2, itsdangerous, click +Required-by: +--- +Name: requests +Version: 2.25.1 +Summary: Python HTTP for Humans. +Requires: charset-normalizer, idna, urllib3, certifi +Required-by: +--- +Name: idna +Version: 3.4 +Summary: Internationalized Domain Names in Applications (IDNA) +Requires: +Required-by: anyio, requests +--- +Name: sniffio +Version: 1.3.0 +Summary: Sniff out which async library your code is running under +Requires: +Required-by: anyio +--- +Name: Werkzeug +Version: 2.2.3 +Summary: The comprehensive WSGI web application library. +Requires: +Required-by: flask +--- +Name: Jinja2 +Version: 3.1.2 +Summary: A small but fast and easy to use stand-alone template engine written in pure python. +Requires: MarkupSafe +Required-by: flask +--- +Name: itsdangerous +Version: 2.1.2 +Summary: Safely pass data to untrusted environments and back. +Requires: +Required-by: flask +--- +Name: click +Version: 8.1.3 +Summary: Composable command line interface toolkit +Requires: +Required-by: flask +--- +Name: charset-normalizer +Version: 3.1.0 +Summary: The Real First Universal Charset Detector. +Requires: +Required-by: requests +--- +Name: urllib3 +Version: 1.26.15 +Summary: HTTP library with thread-safe connection pooling, file post, and more. +Requires: +Required-by: requests +--- +Name: certifi +Version: 2023.5.7 +Summary: Python package for providing Mozilla's CA Bundle. +Requires: +Required-by: requests +--- +Name: MarkupSafe +Version: 2.1.2 +Summary: Safely add untrusted strings to HTML/XML markup. +Requires: +Required-by: Jinja2 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_pip_list.json b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_list.json new file mode 100644 index 00000000..05bd336b --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_list.json @@ -0,0 +1,15 @@ +[ + {"name": "anyio", "version": "3.6.2"}, + {"name": "flask", "version": "2.0.3"}, + {"name": "requests", "version": "2.25.1"}, + {"name": "idna", "version": "3.4"}, + {"name": "sniffio", "version": "1.3.0"}, + {"name": "Werkzeug", "version": "2.2.3"}, + {"name": "Jinja2", "version": "3.1.2"}, + {"name": "itsdangerous", "version": "2.1.2"}, + {"name": "click", "version": "8.1.3"}, + {"name": "charset-normalizer", "version": "3.1.0"}, + {"name": "urllib3", "version": "1.26.15"}, + {"name": "certifi", "version": "2023.5.7"}, + {"name": "MarkupSafe", "version": "2.1.2"} +] diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt new file mode 100644 index 00000000..f1d6f86f --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt @@ -0,0 +1,77 @@ +Name: anyio +Version: 3.6.2 +Summary: High level compatibility library for multiple async event loop implementations +Requires: idna, sniffio +Required-by: +--- +Name: flask +Version: 2.0.3 +Summary: A simple framework for building complex web applications. +Requires: Werkzeug, Jinja2, itsdangerous, click +Required-by: +--- +Name: requests +Version: 2.25.1 +Summary: Python HTTP for Humans. +Requires: charset-normalizer, idna, urllib3, certifi +Required-by: +--- +Name: idna +Version: 3.4 +Summary: Internationalized Domain Names in Applications (IDNA) +Requires: +Required-by: anyio, requests +--- +Name: sniffio +Version: 1.3.0 +Summary: Sniff out which async library your code is running under +Requires: +Required-by: anyio +--- +Name: Werkzeug +Version: 2.2.3 +Summary: The comprehensive WSGI web application library. +Requires: +Required-by: flask +--- +Name: Jinja2 +Version: 3.1.2 +Summary: A small but fast and easy to use stand-alone template engine written in pure python. +Requires: MarkupSafe +Required-by: flask +--- +Name: itsdangerous +Version: 2.1.2 +Summary: Safely pass data to untrusted environments and back. +Requires: +Required-by: flask +--- +Name: click +Version: 8.1.3 +Summary: Composable command line interface toolkit +Requires: +Required-by: flask +--- +Name: charset-normalizer +Version: 3.1.0 +Summary: The Real First Universal Charset Detector. +Requires: +Required-by: requests +--- +Name: urllib3 +Version: 1.26.15 +Summary: HTTP library with thread-safe connection pooling, file post, and more. +Requires: +Required-by: requests +--- +Name: certifi +Version: 2023.5.7 +Summary: Python package for providing Mozilla's CA Bundle. +Requires: +Required-by: requests +--- +Name: MarkupSafe +Version: 2.1.2 +Summary: Safely add untrusted strings to HTML/XML markup. +Requires: +Required-by: Jinja2 From f73f5118cf3200da4c7adf36ba9a00a7039be1d0 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 20 Apr 2026 15:25:19 +0300 Subject: [PATCH 2/6] ci: install uv in PR workflow for PythonUvProvider tests TC-3855 The PythonUvProvider unit tests require the uv binary to be available, since the constructor eagerly validates it via Operations.getExecutable(). This follows the same pattern as other providers (cargo, go, gradle). Co-Authored-By: Claude Opus 4.6 Assisted-by: Claude Code --- .github/workflows/pr.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3979aed2..9b83a1f2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -43,6 +43,8 @@ jobs: go-version: '1.20.1' - name: Install pnpm run: npm install -g pnpm + - name: Install uv + uses: astral-sh/setup-uv@v6 - name: get Python location id: python-location run: | From e8e4756977572bb79a5da3617cfbd6861ce67e2d Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Tue, 21 Apr 2026 10:41:46 +0300 Subject: [PATCH 3/6] fix(python): update manifest references in Python providers to use manifestPath --- .../providers/PythonPyprojectProvider.java | 4 ++-- .../trustifyda/providers/PythonUvProvider.java | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java index 4f590c21..32838c91 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java @@ -341,7 +341,7 @@ static String canonicalize(String name) { private TomlParseResult getToml() throws IOException { if (cachedToml == null) { - cachedToml = PyprojectTomlUtils.parseToml(manifest); + cachedToml = PyprojectTomlUtils.parseToml(manifestPath); } return cachedToml; } @@ -408,7 +408,7 @@ private void rejectPoetryDependencies() throws IOException { } private void collectIgnoredDeps() throws IOException { - collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifest, getToml()); + collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifestPath, getToml()); } List parseDependencyStrings() throws IOException { diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java index 9f3ef8e8..7ff80a0a 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java @@ -81,7 +81,7 @@ public void validateLockFile(Path lockFileDir) { public Content provideStack() throws IOException { rejectPoetryDependencies(); collectIgnoredDeps(); - Path manifestDir = manifest.toAbsolutePath().getParent(); + Path manifestDir = manifestPath.toAbsolutePath().getParent(); String listJson = getUvPipListOutput(manifestDir); UvDependencyData data = buildDependencyGraph(manifestDir, listJson); @@ -96,7 +96,7 @@ public Content provideStack() throws IOException { } } - String manifestContent = Files.readString(manifest); + String manifestContent = Files.readString(manifestPath); handleIgnoredDependencies(manifestContent, sbom); return new Content( sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE); @@ -106,7 +106,7 @@ public Content provideStack() throws IOException { public Content provideComponent() throws IOException { rejectPoetryDependencies(); collectIgnoredDeps(); - Path manifestDir = manifest.toAbsolutePath().getParent(); + Path manifestDir = manifestPath.toAbsolutePath().getParent(); String listJson = getUvPipListOutput(manifestDir); Map packages = parseUvPipList(listJson); @@ -123,7 +123,7 @@ public Content provideComponent() throws IOException { } } - String manifestContent = Files.readString(manifest); + String manifestContent = Files.readString(manifestPath); handleIgnoredDependencies(manifestContent, sbom); return new Content( sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE); @@ -314,7 +314,7 @@ static String canonicalize(String name) { private TomlParseResult getToml() throws IOException { if (cachedToml == null) { - cachedToml = PyprojectTomlUtils.parseToml(manifest); + cachedToml = PyprojectTomlUtils.parseToml(manifestPath); } return cachedToml; } @@ -355,7 +355,7 @@ public String readLicenseFromManifest() { } catch (IOException e) { log.fine("Failed to parse pyproject.toml for license: " + e.getMessage()); } - return LicenseUtils.readLicenseFile(manifest); + return LicenseUtils.readLicenseFile(manifestPath); } @Override @@ -381,7 +381,7 @@ private void rejectPoetryDependencies() throws IOException { } private void collectIgnoredDeps() throws IOException { - collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifest, getToml()); + collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifestPath, getToml()); } static final class UvPackage { From a9174b5c6e236882b617976a933e43c751812129 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Thu, 23 Apr 2026 12:51:35 +0300 Subject: [PATCH 4/6] refactor(python): switch UV provider from pip list/show to uv export Replace the two-command approach (uv pip list + uv pip show) with a single uv export --format requirements.txt --frozen --no-hashes --no-dev --no-emit-project, aligning with the JS client implementation. Also extract duplicated canonicalize() method from PythonUvProvider and PythonPyprojectProvider into PyprojectTomlUtils, and convert UvPackage and UvDependencyData from static final classes to records. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- .../providers/PythonPyprojectProvider.java | 14 +- .../providers/PythonUvProvider.java | 271 +++++++----------- .../trustifyda/utils/PyprojectTomlUtils.java | 8 + .../Python_Pyproject_Provider_Test.java | 10 +- .../providers/Python_Uv_Provider_Test.java | 95 +++--- .../pip/pip_pyproject_toml_uv/uv_export.txt | 30 ++ .../pip_pyproject_toml_uv/uv_pip_list.json | 15 - .../pip/pip_pyproject_toml_uv/uv_pip_show.txt | 77 ----- .../uv_export.txt | 30 ++ .../uv_pip_list.json | 15 - .../uv_pip_show.txt | 77 ----- 12 files changed, 224 insertions(+), 420 deletions(-) create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_export.txt delete mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_list.json delete mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt create mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_export.txt delete mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_list.json delete mode 100644 src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt diff --git a/README.md b/README.md index 0dce42c4..8c6f8e9d 100644 --- a/README.md +++ b/README.md @@ -608,7 +608,7 @@ Python support works with both `requirements.txt` and `pyproject.toml` manifest ##### uv Support -When a `uv.lock` file is present alongside `pyproject.toml`, the client automatically uses the [uv](https://docs.astral.sh/uv/) package manager for dependency resolution instead of pip. Dependency data is collected via `uv pip list --format=json` and `uv pip show`. No additional configuration is required — the provider is selected automatically based on lock file detection. +When a `uv.lock` file is present alongside `pyproject.toml`, the client automatically uses the [uv](https://docs.astral.sh/uv/) package manager for dependency resolution instead of pip. Dependency data is collected via `uv export --format requirements.txt --frozen --no-hashes --no-dev`. No additional configuration is required — the provider is selected automatically based on lock file detection. To use a custom uv binary, set `TRUSTIFY_DA_UV_PATH` to the path of your uv executable. diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java index 32838c91..05ca5cba 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java @@ -132,7 +132,7 @@ private void addDependencyTree( PackageURL packageURL = toPurl(pkg.name, pkg.version); sbom.addDependency(source, packageURL, null); - String key = canonicalize(pkg.name); + String key = PyprojectTomlUtils.canonicalize(pkg.name); if (!visited.add(key)) { return; } @@ -263,7 +263,7 @@ PipReportData parsePipReport(String reportJson) throws IOException { } String name = extractDepName(reqStr); if (name != null) { - directDepNames.add(canonicalize(name)); + directDepNames.add(PyprojectTomlUtils.canonicalize(name)); } } } @@ -282,7 +282,7 @@ PipReportData parsePipReport(String reportJson) throws IOException { if (name == null) { continue; } - String key = canonicalize(name); + String key = PyprojectTomlUtils.canonicalize(name); graph.put(key, new PipPackage(name, version, new ArrayList<>())); } @@ -296,7 +296,7 @@ PipReportData parsePipReport(String reportJson) throws IOException { if (name == null) { continue; } - String key = canonicalize(name); + String key = PyprojectTomlUtils.canonicalize(name); PipPackage pipPkg = graph.get(key); if (pipPkg == null) { continue; @@ -314,7 +314,7 @@ PipReportData parsePipReport(String reportJson) throws IOException { if (depName == null) { continue; } - String depKey = canonicalize(depName); + String depKey = PyprojectTomlUtils.canonicalize(depName); if (graph.containsKey(depKey)) { pipPkg.children.add(depKey); } @@ -335,10 +335,6 @@ static String extractDepName(String req) { return m.find() ? m.group(1) : null; } - static String canonicalize(String name) { - return name.toLowerCase().replaceAll("[-_.]+", "-"); - } - private TomlParseResult getToml() throws IOException { if (cachedToml == null) { cachedToml = PyprojectTomlUtils.parseToml(manifestPath); diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java index 7ff80a0a..97ebff32 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java @@ -16,7 +16,6 @@ */ package io.github.guacsec.trustifyda.providers; -import com.fasterxml.jackson.databind.JsonNode; import com.github.packageurl.PackageURL; import io.github.guacsec.trustifyda.Api; import io.github.guacsec.trustifyda.license.LicenseUtils; @@ -32,7 +31,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -40,15 +38,15 @@ import java.util.Set; import java.util.logging.Logger; import java.util.stream.Collectors; -import org.tomlj.TomlArray; import org.tomlj.TomlParseResult; /** * Provider for Python projects using {@code pyproject.toml} with the uv package manager. * - *

    Dependency resolution is performed via {@code uv pip list --format=json} and {@code uv pip - * show}, which are pip-compatible output formats. The provider is selected by {@link + *

    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 { @@ -56,8 +54,7 @@ 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_PIP_LIST = "TRUSTIFY_DA_UV_PIP_LIST"; - static final String PROP_TRUSTIFY_DA_UV_PIP_SHOW = "TRUSTIFY_DA_UV_PIP_SHOW"; + static final String PROP_TRUSTIFY_DA_UV_EXPORT = "TRUSTIFY_DA_UV_EXPORT"; private final String uvExecutable; private Set collectedIgnoredDeps; @@ -82,17 +79,17 @@ public Content provideStack() throws IOException { rejectPoetryDependencies(); collectIgnoredDeps(); Path manifestDir = manifestPath.toAbsolutePath().getParent(); - String listJson = getUvPipListOutput(manifestDir); - UvDependencyData data = buildDependencyGraph(manifestDir, listJson); + 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); + for (String directKey : data.directDeps()) { + UvPackage pkg = data.graph().get(directKey); if (pkg != null) { - addDependencyTree(sbom.getRoot(), pkg, data.graph, sbom, new HashSet<>()); + addDependencyTree(sbom.getRoot(), pkg, data.graph(), sbom, new HashSet<>()); } } @@ -107,19 +104,17 @@ public Content provideComponent() throws IOException { rejectPoetryDependencies(); collectIgnoredDeps(); Path manifestDir = manifestPath.toAbsolutePath().getParent(); - String listJson = getUvPipListOutput(manifestDir); - Map packages = parseUvPipList(listJson); + String exportOutput = getUvExportOutput(manifestDir); + UvDependencyData data = parseUvExport(exportOutput); Sbom sbom = SbomFactory.newInstance(); sbom.addRoot( toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest()); - List directDeps = getDirectDependencyNames(); - for (String depName : directDeps) { - String key = canonicalize(depName); - UvPackage pkg = packages.get(key); + for (String key : data.directDeps()) { + UvPackage pkg = data.graph().get(key); if (pkg != null) { - sbom.addDependency(sbom.getRoot(), toPurl(pkg.name, pkg.version), null); + sbom.addDependency(sbom.getRoot(), toPurl(pkg.name(), pkg.version()), null); } } @@ -135,15 +130,15 @@ private void addDependencyTree( Map graph, Sbom sbom, Set visited) { - PackageURL packageURL = toPurl(pkg.name, pkg.version); + PackageURL packageURL = toPurl(pkg.name(), pkg.version()); sbom.addDependency(source, packageURL, null); - String key = canonicalize(pkg.name); + String key = PyprojectTomlUtils.canonicalize(pkg.name()); if (!visited.add(key)) { return; } - for (String childKey : pkg.children) { + for (String childKey : pkg.children()) { UvPackage child = graph.get(childKey); if (child != null) { addDependencyTree(packageURL, child, graph, sbom, visited); @@ -152,164 +147,126 @@ private void addDependencyTree( } /** - * Builds the full dependency graph by combining {@code uv pip list --format=json} (all packages - * with versions) and {@code uv pip show} (dependency relationships via Requires field). + * 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 buildDependencyGraph(Path manifestDir, String listJson) throws IOException { - Map packages = parseUvPipList(listJson); - - // Get dependency relationships via uv pip show - if (!packages.isEmpty()) { - List packageNames = - packages.values().stream().map(pkg -> pkg.name).collect(Collectors.toList()); - String showOutput = getUvPipShowOutput(manifestDir, packageNames); - parseUvPipShow(showOutput, packages); - } + UvDependencyData parseUvExport(String exportOutput) throws IOException { + String projectName = PyprojectTomlUtils.canonicalize(getRootComponentName()); + Map packages = new HashMap<>(); + List directDeps = new ArrayList<>(); + List parentChildPairs = new ArrayList<>(); - List directDeps = - getDirectDependencyNames().stream() - .map(PythonUvProvider::canonicalize) - .filter(packages::containsKey) - .collect(Collectors.toList()); + String currentKey = null; + boolean inViaBlock = false; - return new UvDependencyData(directDeps, packages); - } + for (String line : exportOutput.split("\\r?\\n")) { + String trimmed = line.trim(); - /** - * Parses the JSON output of {@code uv pip list --format=json}. The output is a JSON array of - * objects with {@code name} and {@code version} fields: - * - *

    -   * [{"name": "anyio", "version": "3.6.2"}, ...]
    -   * 
    - */ - Map parseUvPipList(String listJson) throws IOException { - JsonNode listArray = objectMapper.readTree(listJson); - Map packages = new HashMap<>(); - if (listArray == null || !listArray.isArray()) { - return packages; - } - for (JsonNode entry : listArray) { - String name = entry.has("name") ? entry.get("name").asText() : null; - String version = entry.has("version") ? entry.get("version").asText() : null; - if (name != null) { - String key = canonicalize(name); - packages.put(key, new UvPackage(name, version, new ArrayList<>())); + if (trimmed.isEmpty()) { + inViaBlock = false; + continue; } - } - return packages; - } - /** - * Parses the text output of {@code uv pip show} to extract dependency relationships. Each package - * block is separated by {@code ---} and contains a {@code Requires:} field listing dependencies. - */ - void parseUvPipShow(String showOutput, Map packages) { - List blocks = splitPipShowBlocks(showOutput); - for (String block : blocks) { - String name = extractShowField(block, "Name:"); - if (name == null) { + // Skip top-level comments (header lines) + if (line.startsWith("#")) { continue; } - String key = canonicalize(name); - UvPackage pkg = packages.get(key); - if (pkg == null) { + + // Skip editable installs (workspace members) + if (line.startsWith("-e ")) { + currentKey = null; + inViaBlock = false; continue; } - String requires = extractShowField(block, "Requires:"); - if (requires != null && !requires.isBlank()) { - Arrays.stream(requires.split(",")) - .map(String::trim) - .filter(dep -> !dep.isEmpty()) - .forEach( - dep -> { - String depKey = canonicalize(dep); - if (packages.containsKey(depKey)) { - pkg.children.add(depKey); - } - }); + + // Package line: name==version [; env-marker] + if (!line.startsWith(" ") && trimmed.contains("==")) { + inViaBlock = false; + 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; } - } - } - private List splitPipShowBlocks(String showOutput) { - return Arrays.stream(showOutput.split("\\r?\\n---\\r?\\n")) - .filter(block -> !block.isBlank()) - .collect(Collectors.toList()); - } + // 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; + } - private String extractShowField(String block, String fieldName) { - int fieldIndex = block.indexOf(fieldName); - if (fieldIndex == -1) { - return null; - } - String afterField = block.substring(fieldIndex + fieldName.length()); - int endOfLine = afterField.indexOf('\n'); - if (endOfLine == -1) { - return afterField.trim(); + // 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); + } + } } - return afterField.substring(0, endOfLine).trim(); - } - String getUvPipListOutput(Path manifestDir) { - String envValue = Environment.get(PROP_TRUSTIFY_DA_UV_PIP_LIST); - if (envValue != null && !envValue.isBlank()) { - return envValue; + // 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]); + } } - String[] cmd = {uvExecutable, "pip", "list", "--format=json"}; - Operations.ProcessExecOutput result = - Operations.runProcessGetFullOutput(manifestDir, cmd, null); - if (result.getExitCode() != 0) { - throw new RuntimeException( - String.format( - "uv pip list command failed with exit code %d: %s", - result.getExitCode(), result.getError())); + return new UvDependencyData(directDeps, packages); + } + + private static void recordViaParent( + String parentName, + String childKey, + String projectName, + List directDeps, + List parentChildPairs) { + String parentKey = PyprojectTomlUtils.canonicalize(parentName); + if (parentKey.equals(projectName)) { + if (!directDeps.contains(childKey)) { + directDeps.add(childKey); + } + } else { + parentChildPairs.add(new String[] {parentKey, childKey}); } - return result.getOutput(); } - String getUvPipShowOutput(Path manifestDir, List packageNames) { - String envValue = Environment.get(PROP_TRUSTIFY_DA_UV_PIP_SHOW); + String getUvExportOutput(Path manifestDir) { + String envValue = Environment.get(PROP_TRUSTIFY_DA_UV_EXPORT); if (envValue != null && !envValue.isBlank()) { return envValue; } - List cmdParts = new ArrayList<>(); - cmdParts.add(uvExecutable); - cmdParts.add("pip"); - cmdParts.add("show"); - cmdParts.addAll(packageNames); - - String[] cmd = cmdParts.toArray(new String[0]); + 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 pip show command failed with exit code %d: %s", + "uv export command failed with exit code %d: %s", result.getExitCode(), result.getError())); } return result.getOutput(); } - private List getDirectDependencyNames() throws IOException { - TomlParseResult toml = getToml(); - List deps = new ArrayList<>(); - TomlArray projectDeps = toml.getArray("project.dependencies"); - if (projectDeps != null) { - for (int i = 0; i < projectDeps.size(); i++) { - String dep = projectDeps.getString(i); - deps.add(PythonControllerBase.getDependencyName(dep)); - } - } - return deps; - } - - static String canonicalize(String name) { - return name.toLowerCase().replaceAll("[-_.]+", "-"); - } - // --- TOML parsing (shared with PythonPyprojectProvider) --- private TomlParseResult getToml() throws IOException { @@ -384,25 +341,7 @@ private void collectIgnoredDeps() throws IOException { collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifestPath, getToml()); } - static final class UvPackage { - final String name; - final String version; - final List children; + record UvPackage(String name, String version, List children) {} - UvPackage(String name, String version, List children) { - this.name = name; - this.version = version; - this.children = children; - } - } - - static final class UvDependencyData { - final List directDeps; - final Map graph; - - UvDependencyData(List directDeps, Map graph) { - this.directDeps = directDeps; - this.graph = graph; - } - } + record UvDependencyData(List directDeps, Map graph) {} } diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java index 78d735af..8aef87b3 100644 --- a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java +++ b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java @@ -104,4 +104,12 @@ public static String getLicense(TomlParseResult toml) { public static boolean hasPoetryDependencies(TomlParseResult toml) { return toml.getTable("tool.poetry.dependencies") != 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("[-_.]+", "-"); + } } 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 index cbebb26b..4193f98c 100644 --- 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 @@ -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.file.Files; import java.nio.file.Path; @@ -106,63 +107,52 @@ void test_validate_lock_file_throws_without_uv_lock() throws IOException { } @Test - void test_parseUvPipList_parses_packages() throws IOException { - Path listPath = Path.of(UV_FIXTURE, "uv_pip_list.json"); + 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 listJson = Files.readString(listPath); - var packages = provider.parseUvPipList(listJson); - assertThat(packages).containsKeys("anyio", "flask", "requests", "idna", "sniffio"); - assertThat(packages.get("anyio").version).isEqualTo("3.6.2"); - assertThat(packages.get("flask").version).isEqualTo("2.0.3"); + 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_parseUvPipShow_builds_children() throws IOException { - Path listPath = Path.of(UV_FIXTURE, "uv_pip_list.json"); - Path showPath = Path.of(UV_FIXTURE, "uv_pip_show.txt"); + 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 listJson = Files.readString(listPath); - String showOutput = Files.readString(showPath); - var packages = provider.parseUvPipList(listJson); - provider.parseUvPipShow(showOutput, packages); + String exportOutput = Files.readString(exportPath); + var data = provider.parseUvExport(exportOutput); - var requestsPkg = packages.get("requests"); + var requestsPkg = data.graph().get("requests"); assertThat(requestsPkg).isNotNull(); - assertThat(requestsPkg.children) + assertThat(requestsPkg.children()) .containsExactlyInAnyOrder("charset-normalizer", "idna", "urllib3", "certifi"); - var anyioPkg = packages.get("anyio"); + var anyioPkg = data.graph().get("anyio"); assertThat(anyioPkg).isNotNull(); - assertThat(anyioPkg.children).containsExactlyInAnyOrder("idna", "sniffio"); + assertThat(anyioPkg.children()).containsExactlyInAnyOrder("idna", "sniffio"); - var flaskPkg = packages.get("flask"); + var flaskPkg = data.graph().get("flask"); assertThat(flaskPkg).isNotNull(); - assertThat(flaskPkg.children) + assertThat(flaskPkg.children()) .containsExactlyInAnyOrder("werkzeug", "jinja2", "itsdangerous", "click"); - var jinja2Pkg = packages.get("jinja2"); + var jinja2Pkg = data.graph().get("jinja2"); assertThat(jinja2Pkg).isNotNull(); - assertThat(jinja2Pkg.children).containsExactly("markupsafe"); + assertThat(jinja2Pkg.children()).containsExactly("markupsafe"); } @Test - void test_buildDependencyGraph_identifies_direct_deps() throws IOException { - Path listPath = Path.of(UV_FIXTURE, "uv_pip_list.json"); - Path showPath = Path.of(UV_FIXTURE, "uv_pip_show.txt"); + 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 listJson = Files.readString(listPath); - String showOutput = Files.readString(showPath); - - System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW, showOutput); - try { - var data = provider.buildDependencyGraph(Path.of(UV_FIXTURE), listJson); - assertThat(data.directDeps).containsExactlyInAnyOrder("anyio", "flask", "requests"); - } finally { - System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW); - } + String exportOutput = Files.readString(exportPath); + var data = provider.parseUvExport(exportOutput); + assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "flask", "requests"); } @Test @@ -180,13 +170,11 @@ void test_getRootComponentVersion_reads_pep621_version() { } @Test - void test_provideStack_with_uv_pip() throws IOException { + void test_provideStack_with_uv_export() throws IOException { Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); - String listJson = Files.readString(Path.of(UV_FIXTURE, "uv_pip_list.json")); - String showOutput = Files.readString(Path.of(UV_FIXTURE, "uv_pip_show.txt")); + String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export.txt")); - System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST, listJson); - System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW, showOutput); + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput); try { var provider = new PythonUvProvider(pyprojectPath); var content = provider.provideStack(); @@ -204,17 +192,16 @@ void test_provideStack_with_uv_pip() throws IOException { } catch (RuntimeException | NoClassDefFoundError e) { Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage()); } finally { - System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST); - System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW); + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT); } } @Test - void test_provideComponent_with_uv_pip() throws IOException { + void test_provideComponent_with_uv_export() throws IOException { Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); - String listJson = Files.readString(Path.of(UV_FIXTURE, "uv_pip_list.json")); + String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export.txt")); - System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST, listJson); + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput); try { var provider = new PythonUvProvider(pyprojectPath); var content = provider.provideComponent(); @@ -227,18 +214,16 @@ void test_provideComponent_with_uv_pip() throws IOException { } catch (RuntimeException | NoClassDefFoundError e) { Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage()); } finally { - System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST); + 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 listJson = Files.readString(Path.of(UV_IGNORE_FIXTURE, "uv_pip_list.json")); - String showOutput = Files.readString(Path.of(UV_IGNORE_FIXTURE, "uv_pip_show.txt")); + String exportOutput = Files.readString(Path.of(UV_IGNORE_FIXTURE, "uv_export.txt")); - System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST, listJson); - System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW, showOutput); + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput); try { var provider = new PythonUvProvider(pyprojectPath); var content = provider.provideStack(); @@ -249,15 +234,15 @@ void test_ignored_dependencies_in_uv_project() throws IOException { } catch (RuntimeException | NoClassDefFoundError e) { Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage()); } finally { - System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_LIST); - System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_PIP_SHOW); + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT); } } @Test void test_canonicalize() { - assertThat(PythonUvProvider.canonicalize("charset_normalizer")).isEqualTo("charset-normalizer"); - assertThat(PythonUvProvider.canonicalize("Jinja2")).isEqualTo("jinja2"); - assertThat(PythonUvProvider.canonicalize("MarkupSafe")).isEqualTo("markupsafe"); + 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/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/uv_pip_list.json b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_list.json deleted file mode 100644 index 05bd336b..00000000 --- a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_list.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - {"name": "anyio", "version": "3.6.2"}, - {"name": "flask", "version": "2.0.3"}, - {"name": "requests", "version": "2.25.1"}, - {"name": "idna", "version": "3.4"}, - {"name": "sniffio", "version": "1.3.0"}, - {"name": "Werkzeug", "version": "2.2.3"}, - {"name": "Jinja2", "version": "3.1.2"}, - {"name": "itsdangerous", "version": "2.1.2"}, - {"name": "click", "version": "8.1.3"}, - {"name": "charset-normalizer", "version": "3.1.0"}, - {"name": "urllib3", "version": "1.26.15"}, - {"name": "certifi", "version": "2023.5.7"}, - {"name": "MarkupSafe", "version": "2.1.2"} -] diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt deleted file mode 100644 index f1d6f86f..00000000 --- a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_pip_show.txt +++ /dev/null @@ -1,77 +0,0 @@ -Name: anyio -Version: 3.6.2 -Summary: High level compatibility library for multiple async event loop implementations -Requires: idna, sniffio -Required-by: ---- -Name: flask -Version: 2.0.3 -Summary: A simple framework for building complex web applications. -Requires: Werkzeug, Jinja2, itsdangerous, click -Required-by: ---- -Name: requests -Version: 2.25.1 -Summary: Python HTTP for Humans. -Requires: charset-normalizer, idna, urllib3, certifi -Required-by: ---- -Name: idna -Version: 3.4 -Summary: Internationalized Domain Names in Applications (IDNA) -Requires: -Required-by: anyio, requests ---- -Name: sniffio -Version: 1.3.0 -Summary: Sniff out which async library your code is running under -Requires: -Required-by: anyio ---- -Name: Werkzeug -Version: 2.2.3 -Summary: The comprehensive WSGI web application library. -Requires: -Required-by: flask ---- -Name: Jinja2 -Version: 3.1.2 -Summary: A small but fast and easy to use stand-alone template engine written in pure python. -Requires: MarkupSafe -Required-by: flask ---- -Name: itsdangerous -Version: 2.1.2 -Summary: Safely pass data to untrusted environments and back. -Requires: -Required-by: flask ---- -Name: click -Version: 8.1.3 -Summary: Composable command line interface toolkit -Requires: -Required-by: flask ---- -Name: charset-normalizer -Version: 3.1.0 -Summary: The Real First Universal Charset Detector. -Requires: -Required-by: requests ---- -Name: urllib3 -Version: 1.26.15 -Summary: HTTP library with thread-safe connection pooling, file post, and more. -Requires: -Required-by: requests ---- -Name: certifi -Version: 2023.5.7 -Summary: Python package for providing Mozilla's CA Bundle. -Requires: -Required-by: requests ---- -Name: MarkupSafe -Version: 2.1.2 -Summary: Safely add untrusted strings to HTML/XML markup. -Requires: -Required-by: Jinja2 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 diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_list.json b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_list.json deleted file mode 100644 index 05bd336b..00000000 --- a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_list.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - {"name": "anyio", "version": "3.6.2"}, - {"name": "flask", "version": "2.0.3"}, - {"name": "requests", "version": "2.25.1"}, - {"name": "idna", "version": "3.4"}, - {"name": "sniffio", "version": "1.3.0"}, - {"name": "Werkzeug", "version": "2.2.3"}, - {"name": "Jinja2", "version": "3.1.2"}, - {"name": "itsdangerous", "version": "2.1.2"}, - {"name": "click", "version": "8.1.3"}, - {"name": "charset-normalizer", "version": "3.1.0"}, - {"name": "urllib3", "version": "1.26.15"}, - {"name": "certifi", "version": "2023.5.7"}, - {"name": "MarkupSafe", "version": "2.1.2"} -] diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt deleted file mode 100644 index f1d6f86f..00000000 --- a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_pip_show.txt +++ /dev/null @@ -1,77 +0,0 @@ -Name: anyio -Version: 3.6.2 -Summary: High level compatibility library for multiple async event loop implementations -Requires: idna, sniffio -Required-by: ---- -Name: flask -Version: 2.0.3 -Summary: A simple framework for building complex web applications. -Requires: Werkzeug, Jinja2, itsdangerous, click -Required-by: ---- -Name: requests -Version: 2.25.1 -Summary: Python HTTP for Humans. -Requires: charset-normalizer, idna, urllib3, certifi -Required-by: ---- -Name: idna -Version: 3.4 -Summary: Internationalized Domain Names in Applications (IDNA) -Requires: -Required-by: anyio, requests ---- -Name: sniffio -Version: 1.3.0 -Summary: Sniff out which async library your code is running under -Requires: -Required-by: anyio ---- -Name: Werkzeug -Version: 2.2.3 -Summary: The comprehensive WSGI web application library. -Requires: -Required-by: flask ---- -Name: Jinja2 -Version: 3.1.2 -Summary: A small but fast and easy to use stand-alone template engine written in pure python. -Requires: MarkupSafe -Required-by: flask ---- -Name: itsdangerous -Version: 2.1.2 -Summary: Safely pass data to untrusted environments and back. -Requires: -Required-by: flask ---- -Name: click -Version: 8.1.3 -Summary: Composable command line interface toolkit -Requires: -Required-by: flask ---- -Name: charset-normalizer -Version: 3.1.0 -Summary: The Real First Universal Charset Detector. -Requires: -Required-by: requests ---- -Name: urllib3 -Version: 1.26.15 -Summary: HTTP library with thread-safe connection pooling, file post, and more. -Requires: -Required-by: requests ---- -Name: certifi -Version: 2023.5.7 -Summary: Python package for providing Mozilla's CA Bundle. -Requires: -Required-by: requests ---- -Name: MarkupSafe -Version: 2.1.2 -Summary: Safely add untrusted strings to HTML/XML markup. -Requires: -Required-by: Jinja2 From 5482e178031866c1b7e2c24a4d73064bc797149c Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Thu, 23 Apr 2026 15:12:03 +0300 Subject: [PATCH 5/6] fix(python): walk up parent directories to find uv.lock for workspace members The factory and validateLockFile only checked the manifest directory for uv.lock, which fails for uv workspace members where the lock file lives at the workspace root. Add parent directory walk-up matching the JS client's _findLockFileDir pattern, with TRUSTIFY_DA_WORKSPACE_DIR override, git root boundary, and uv workspace root detection. Co-Authored-By: Claude Opus 4.6 --- .../providers/PythonProviderFactory.java | 66 +++++++- .../providers/PythonUvProvider.java | 14 +- .../trustifyda/utils/PyprojectTomlUtils.java | 17 ++ .../PythonProviderFactoryLockFileTest.java | 151 ++++++++++++++++++ 4 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java index a890c44d..9502cfe3 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java @@ -16,6 +16,9 @@ */ package io.github.guacsec.trustifyda.providers; +import io.github.guacsec.trustifyda.tools.Operations; +import io.github.guacsec.trustifyda.utils.Environment; +import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; @@ -32,8 +35,9 @@ public final class PythonProviderFactory { /** * Creates a Python provider for {@code pyproject.toml} manifests by checking for known lock files - * in the manifest directory. When {@code uv.lock} is present, returns a {@link PythonUvProvider}; - * otherwise falls back to {@link PythonPyprojectProvider} (pip-based). + * in the manifest directory and parent directories (for workspace members). When {@code uv.lock} + * is present, returns a {@link PythonUvProvider}; otherwise falls back to {@link + * PythonPyprojectProvider} (pip-based). * * @param manifestPath the path to the pyproject.toml manifest * @return the matching Python provider @@ -41,13 +45,71 @@ public final class PythonProviderFactory { public static PythonProvider create(final Path manifestPath) { var manifestDir = manifestPath.getParent(); + // Check manifest directory first (fast path) for (var entry : PYTHON_PROVIDERS.entrySet()) { if (Files.isRegularFile(manifestDir.resolve(entry.getKey()))) { return entry.getValue().apply(manifestPath); } } + // Walk up parent directories to find lock file at workspace root + Path lockFileDir = findLockFileDirInParents(manifestDir); + if (lockFileDir != null) { + for (var entry : PYTHON_PROVIDERS.entrySet()) { + if (Files.isRegularFile(lockFileDir.resolve(entry.getKey()))) { + return entry.getValue().apply(manifestPath); + } + } + } + // Unlike JavaScript, pip fallback is valid — no lock file required return new PythonPyprojectProvider(manifestPath); } + + /** + * Walks up from the given directory to find a parent containing a known Python lock file. + * Respects {@code TRUSTIFY_DA_WORKSPACE_DIR} override, stops at uv workspace root boundaries and + * the git root. + * + * @param startDir the directory to start searching from (typically the manifest directory) + * @return the directory containing the lock file, or {@code null} if not found + */ + static Path findLockFileDirInParents(Path startDir) { + // Environment override takes precedence + String workspaceDirOverride = Environment.get("TRUSTIFY_DA_WORKSPACE_DIR"); + if (workspaceDirOverride != null && !workspaceDirOverride.isBlank()) { + Path overrideDir = Path.of(workspaceDirOverride); + for (String lockFile : PYTHON_PROVIDERS.keySet()) { + if (Files.isRegularFile(overrideDir.resolve(lockFile))) { + return overrideDir; + } + } + return null; + } + + String gitRoot = Operations.getGitRootDir(startDir.toString()).orElse(null); + Path boundary = gitRoot != null ? Path.of(gitRoot) : startDir.toAbsolutePath().getRoot(); + + Path current = startDir.toAbsolutePath().normalize().getParent(); + while (current != null) { + for (String lockFile : PYTHON_PROVIDERS.keySet()) { + if (Files.isRegularFile(current.resolve(lockFile))) { + return current; + } + } + + // Stop at uv workspace root boundary + if (PyprojectTomlUtils.isUvWorkspaceRoot(current)) { + return null; + } + + if (current.equals(boundary.toAbsolutePath().normalize())) { + break; + } + + current = current.getParent(); + } + + return null; + } } diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java index 97ebff32..9d5063e6 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java @@ -67,11 +67,17 @@ public PythonUvProvider(Path manifest) { @Override public void validateLockFile(Path lockFileDir) { - if (!Files.isRegularFile(lockFileDir.resolve(LOCK_FILE))) { - throw new IllegalStateException( - "uv.lock does not exist. Ensure the project is managed by uv" - + " and run 'uv lock' to generate it."); + // 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 diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java index 8aef87b3..a94c7332 100644 --- a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java +++ b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java @@ -112,4 +112,21 @@ public static boolean hasPoetryDependencies(TomlParseResult toml) { 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/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..2154c2a4 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java @@ -0,0 +1,151 @@ +/* + * 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); + } + + /** 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"); + } +} From 7ca7c02b3794b50e00317618e68fc3ccf88cb142 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Sun, 26 Apr 2026 13:43:53 +0300 Subject: [PATCH 6/6] fix(python): align UV provider with JS client behavior - Skip self-referencing editable installs (root project as its own dep) - Require both name and version for editable installs (skip if missing) - Fall back to tool.poetry.name/version for editable install metadata - Validate bare package names in # via comments (skip version specifiers/extras) - Throw on unpinned versions in uv export output - Prevent workspace root walk-up from escaping into parent workspace Co-Authored-By: Claude Opus 4.6 --- .../providers/PythonProviderFactory.java | 6 + .../providers/PythonUvProvider.java | 65 ++++++- .../trustifyda/utils/PyprojectTomlUtils.java | 12 ++ .../PythonProviderFactoryLockFileTest.java | 23 +++ .../providers/Python_Uv_Provider_Test.java | 167 ++++++++++++++++++ 5 files changed, 270 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java index 9502cfe3..8055a582 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java @@ -87,6 +87,12 @@ static Path findLockFileDirInParents(Path startDir) { return null; } + // If startDir itself is a workspace root, don't walk up to avoid escaping into a parent + // workspace + if (PyprojectTomlUtils.isUvWorkspaceRoot(startDir)) { + return null; + } + String gitRoot = Operations.getGitRootDir(startDir.toString()).orElse(null); Path boundary = gitRoot != null ? Path.of(gitRoot) : startDir.toAbsolutePath().getRoot(); diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java index 9d5063e6..60311a70 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java @@ -27,6 +27,7 @@ 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; @@ -37,6 +38,7 @@ 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; @@ -181,16 +183,19 @@ UvDependencyData parseUvExport(String exportOutput) throws IOException { continue; } - // Skip editable installs (workspace members) + // Editable installs are workspace members — resolve name/version from their pyproject.toml if (line.startsWith("-e ")) { - currentKey = null; inViaBlock = false; + currentKey = parseEditableInstall(line, packages, projectName); continue; } // Package line: name==version [; env-marker] - if (!line.startsWith(" ") && trimmed.contains("==")) { + 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] @@ -230,12 +235,66 @@ UvDependencyData parseUvExport(String exportOutput) throws IOException { 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)) { diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java index a94c7332..edd2b2bc 100644 --- a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java +++ b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java @@ -105,6 +105,18 @@ 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. diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java b/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java index 2154c2a4..0deeca37 100644 --- a/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java @@ -116,6 +116,29 @@ void testStopsAtUvWorkspaceRootBoundary(@TempDir Path tempDir) throws IOExceptio 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 { 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 index 4193f98c..7fda0b84 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -28,6 +29,7 @@ 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 { @@ -238,6 +240,171 @@ void test_ignored_dependencies_in_uv_project() throws IOException { } } + @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"))