Skip to content

Commit b7611e0

Browse files
a-orenclaude
andcommitted
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 <noreply@anthropic.com> Assisted-by: Claude Code
1 parent 1baa6a8 commit b7611e0

17 files changed

Lines changed: 1296 additions & 49 deletions

File tree

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ public class TrustifyExample {
182182
<li><a href="https://www.java.com/">Java</a> - <a href="https://maven.apache.org/">Maven</a></li>
183183
<li><a href="https://www.javascript.com//">JavaScript</a> - <a href="https://www.npmjs.com//">Npm</a></li>
184184
<li><a href="https://go.dev//">Golang</a> - <a href="https://go.dev/blog/using-go-modules//">Go Modules</a></li>
185-
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a> (<code>requirements.txt</code>, <code>pyproject.toml</code> with PEP 621 format). <strong>Note:</strong> Poetry-style dependencies (<code>[tool.poetry.dependencies]</code>) are not supported.</li>
185+
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a> / <a href="https://docs.astral.sh/uv/">uv</a> (<code>requirements.txt</code>, <code>pyproject.toml</code> with PEP 621 format). <strong>Note:</strong> Poetry-style dependencies (<code>[tool.poetry.dependencies]</code>) are not supported.</li>
186186
<li><a href="https://gradle.org//">Gradle</a> - <a href="https://gradle.org/install//">Gradle Installation</a></li>
187187
<li><a href="https://www.rust-lang.org/">Rust</a> - <a href="https://doc.rust-lang.org/cargo/">Cargo</a></li>
188188

@@ -393,6 +393,7 @@ System.setProperty("TRUSTIFY_DA_PYTHON3_PATH", "/path/to/python3");
393393
System.setProperty("TRUSTIFY_DA_PIP3_PATH", "/path/to/pip3");
394394
System.setProperty("TRUSTIFY_DA_PYTHON_PATH", "/path/to/python");
395395
System.setProperty("TRUSTIFY_DA_PIP_PATH", "/path/to/pip");
396+
System.setProperty("TRUSTIFY_DA_UV_PATH", "/path/to/custom/uv");
396397
// Configure proxy for all requests
397398
System.setProperty("TRUSTIFY_DA_PROXY_URL", "http://proxy.example.com:8080");
398399
// Configure Maven settings and repository
@@ -504,6 +505,11 @@ following keys for setting custom paths for the said executables.
504505
<td>TRUSTIFY_DA_PIP_PATH</td>
505506
</tr>
506507
<tr>
508+
<td><a href="https://docs.astral.sh/uv/">uv Package Manager</a></td>
509+
<td><em>uv</em></td>
510+
<td>TRUSTIFY_DA_UV_PATH</td>
511+
</tr>
512+
<tr>
507513
<td><a href="https://doc.rust-lang.org/cargo/">Cargo Package Manager</a></td>
508514
<td><em>cargo</em></td>
509515
<td>TRUSTIFY_DA_CARGO_PATH</td>
@@ -600,6 +606,14 @@ TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED=false
600606

601607
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.
602608

609+
##### uv Support
610+
611+
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.
612+
613+
To use a custom uv binary, set `TRUSTIFY_DA_UV_PATH` to the path of your uv executable.
614+
615+
##### pip Support
616+
603617
By default, Python support assumes that the package is installed using the pip/pip3 binary on the system PATH, or of the customized
604618
Binaries passed to environment variables. If the package is not installed , then an error will be thrown.
605619

@@ -683,6 +697,12 @@ Options:
683697
- `--output <path>` - Write SBOM JSON to the specified file
684698
- (default) - Print SBOM JSON to stdout
685699

700+
**SBOM for uv-managed Python project**
701+
```shell
702+
# When uv.lock is present alongside pyproject.toml, the uv provider is used automatically
703+
java -jar trustify-da-java-client-cli.jar sbom /path/to/uv-project/pyproject.toml
704+
```
705+
686706
**Image Analysis**
687707
```shell
688708
java -jar trustify-da-java-client-cli.jar image <image_ref> [<image_ref>...] [--summary|--html]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2023-2025 Trustify Dependency Analytics Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
*
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package io.github.guacsec.trustifyda.providers;
18+
19+
import java.nio.file.Files;
20+
import java.nio.file.Path;
21+
import java.util.Map;
22+
import java.util.function.Function;
23+
24+
/**
25+
* Factory for creating the appropriate {@link PythonProvider} based on lock file presence. Follows
26+
* the same pattern as {@link JavaScriptProviderFactory}.
27+
*/
28+
public final class PythonProviderFactory {
29+
30+
private static final Map<String, Function<Path, PythonProvider>> PYTHON_PROVIDERS =
31+
Map.of(PythonUvProvider.LOCK_FILE, PythonUvProvider::new);
32+
33+
/**
34+
* Creates a Python provider for {@code pyproject.toml} manifests by checking for known lock files
35+
* in the manifest directory. When {@code uv.lock} is present, returns a {@link PythonUvProvider};
36+
* otherwise falls back to {@link PythonPyprojectProvider} (pip-based).
37+
*
38+
* @param manifestPath the path to the pyproject.toml manifest
39+
* @return the matching Python provider
40+
*/
41+
public static PythonProvider create(final Path manifestPath) {
42+
var manifestDir = manifestPath.getParent();
43+
44+
for (var entry : PYTHON_PROVIDERS.entrySet()) {
45+
if (Files.isRegularFile(manifestDir.resolve(entry.getKey()))) {
46+
return entry.getValue().apply(manifestPath);
47+
}
48+
}
49+
50+
// Unlike JavaScript, pip fallback is valid — no lock file required
51+
return new PythonPyprojectProvider(manifestPath);
52+
}
53+
}

src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java

Lines changed: 10 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import io.github.guacsec.trustifyda.sbom.SbomFactory;
2626
import io.github.guacsec.trustifyda.tools.Operations;
2727
import io.github.guacsec.trustifyda.utils.Environment;
28+
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
2829
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
2930
import java.io.IOException;
3031
import java.nio.charset.StandardCharsets;
@@ -42,10 +43,8 @@
4243
import java.util.regex.Matcher;
4344
import java.util.regex.Pattern;
4445
import java.util.stream.Collectors;
45-
import org.tomlj.Toml;
4646
import org.tomlj.TomlArray;
4747
import org.tomlj.TomlParseResult;
48-
import org.tomlj.TomlTable;
4948

5049
/**
5150
* Provider for Python projects using {@code pyproject.toml} with <a
@@ -342,22 +341,16 @@ static String canonicalize(String name) {
342341

343342
private TomlParseResult getToml() throws IOException {
344343
if (cachedToml == null) {
345-
TomlParseResult parsed = Toml.parse(manifest);
346-
if (parsed.hasErrors()) {
347-
throw new IOException(
348-
"Invalid pyproject.toml format: " + parsed.errors().get(0).getMessage());
349-
}
350-
cachedToml = parsed;
344+
cachedToml = PyprojectTomlUtils.parseToml(manifest);
351345
}
352346
return cachedToml;
353347
}
354348

355349
@Override
356350
protected String getRootComponentName() {
357351
try {
358-
TomlParseResult toml = getToml();
359-
String name = toml.getString("project.name");
360-
if (name != null && !name.isBlank()) {
352+
String name = PyprojectTomlUtils.getProjectName(getToml());
353+
if (name != null) {
361354
return name;
362355
}
363356
} catch (IOException e) {
@@ -369,9 +362,8 @@ protected String getRootComponentName() {
369362
@Override
370363
protected String getRootComponentVersion() {
371364
try {
372-
TomlParseResult toml = getToml();
373-
String version = toml.getString("project.version");
374-
if (version != null && !version.isBlank()) {
365+
String version = PyprojectTomlUtils.getProjectVersion(getToml());
366+
if (version != null) {
375367
return version;
376368
}
377369
} catch (IOException e) {
@@ -383,16 +375,10 @@ protected String getRootComponentVersion() {
383375
@Override
384376
public String readLicenseFromManifest() {
385377
try {
386-
TomlParseResult toml = getToml();
387-
String license = toml.getString("project.license");
388-
if (license != null && !license.isBlank()) {
378+
String license = PyprojectTomlUtils.getLicense(getToml());
379+
if (license != null) {
389380
return license;
390381
}
391-
// PEP 639: license may be in project.license.text
392-
String licenseText = toml.getString("project.license.text");
393-
if (licenseText != null && !licenseText.isBlank()) {
394-
return licenseText;
395-
}
396382
} catch (IOException e) {
397383
log.fine("Failed to parse pyproject.toml for license: " + e.getMessage());
398384
}
@@ -414,28 +400,15 @@ protected Set<PackageURL> getIgnoredDependencies(String manifestContent) {
414400
}
415401

416402
private void rejectPoetryDependencies() throws IOException {
417-
TomlParseResult toml = getToml();
418-
TomlTable poetryDeps = toml.getTable("tool.poetry.dependencies");
419-
if (poetryDeps != null) {
403+
if (PyprojectTomlUtils.hasPoetryDependencies(getToml())) {
420404
throw new IllegalStateException(
421405
"Poetry dependencies in pyproject.toml are not supported."
422406
+ " Please use PEP 621 [project.dependencies] format instead.");
423407
}
424408
}
425409

426410
private void collectIgnoredDeps() throws IOException {
427-
TomlParseResult toml = getToml();
428-
List<String> rawLines = Files.readAllLines(manifest);
429-
collectedIgnoredDeps = new HashSet<>();
430-
431-
// [project.dependencies] - PEP 621
432-
TomlArray projectDeps = toml.getArray("project.dependencies");
433-
if (projectDeps != null) {
434-
for (int i = 0; i < projectDeps.size(); i++) {
435-
String dep = projectDeps.getString(i);
436-
checkIgnored(rawLines, dep, dep);
437-
}
438-
}
411+
collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifest, getToml());
439412
}
440413

441414
List<String> parseDependencyStrings() throws IOException {
@@ -453,15 +426,6 @@ List<String> parseDependencyStrings() throws IOException {
453426
return deps;
454427
}
455428

456-
private void checkIgnored(List<String> rawLines, String searchToken, String depIdentifier) {
457-
for (String line : rawLines) {
458-
if (line.contains(searchToken) && containsIgnorePattern(line)) {
459-
collectedIgnoredDeps.add(depIdentifier);
460-
break;
461-
}
462-
}
463-
}
464-
465429
static final class PipPackage {
466430
final String name;
467431
final String version;

0 commit comments

Comments
 (0)