Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,21 @@ UvDependencyData parseUvExport(String exportOutput) throws IOException {
// Package line: name==version [; env-marker]
if (!line.startsWith(" ") && !trimmed.startsWith("#")) {
inViaBlock = false;

// PEP 440 direct references (name @ url) — skip, no pinned version available
if (isDirectReference(trimmed)) {
log.fine("Skipping PEP 440 direct reference: " + trimmed);
currentKey = null;
continue;
}

// Path dependencies (./local, ../local, /absolute, ~/home, C:\win) — skip
if (isPathDependency(trimmed)) {
log.fine("Skipping path dependency: " + trimmed);
currentKey = null;
continue;
}

if (!trimmed.contains("==")) {
throw new IOException("uv export: package '" + trimmed + "' has no pinned version");
}
Expand Down Expand Up @@ -284,6 +299,22 @@ private static String parseEditableInstall(
}
}

private static final Pattern WINDOWS_DRIVE_PATH = Pattern.compile("^[a-zA-Z]:[/\\\\]");

/** Returns {@code true} if the line is a PEP 440 direct reference ({@code name @ url}). */
static boolean isDirectReference(String trimmedLine) {
return trimmedLine.contains(" @ ");
}

/** Returns {@code true} if the line is a local or absolute path dependency. */
static boolean isPathDependency(String trimmedLine) {
return trimmedLine.startsWith("./")
|| trimmedLine.startsWith("../")
|| trimmedLine.startsWith("/")
|| trimmedLine.startsWith("~/")
|| WINDOWS_DRIVE_PATH.matcher(trimmedLine).find();
}

private static final Pattern BARE_PACKAGE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*");

private static void recordViaParent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,212 @@ void test_parseUvExport_via_skips_non_bare_package_names() throws IOException {
assertThat(data.graph().values().stream().allMatch(p -> p.children().isEmpty())).isTrue();
}

/** Verifies that PEP 440 direct references (name @ url) are skipped without throwing. */
@Test
void test_parseUvExport_skips_direct_references() throws IOException {
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
var provider = new PythonUvProvider(pyprojectPath);

String exportOutput =
"# This file was autogenerated by uv\n"
+ "certifi @ git+https://github.com/certifi/python-certifi.git@abcdef1234567890\n"
+ " # via requests\n"
+ "anyio==3.6.2\n"
+ " # via test-project\n";

// Given/When
var data = provider.parseUvExport(exportOutput);

// Then — direct reference is skipped, not in graph
assertThat(data.graph()).doesNotContainKey("certifi");
// anyio after the skipped line is still parsed correctly
assertThat(data.graph()).containsKey("anyio");
assertThat(data.graph().get("anyio").version()).isEqualTo("3.6.2");
assertThat(data.directDeps()).contains("anyio");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test to ensure # via comments after skipped path dependencies do not corrupt the graph

Please add a similar test case for path dependencies. For instance, cover a sequence like: normal package → path dependency + # via → normal package, and assert that currentKey is reset correctly for the path dep and that the # via block is not attached to the wrong package.

}

/** Verifies that path dependencies (./local-package) are skipped without throwing. */
@Test
void test_parseUvExport_skips_path_dependencies() throws IOException {
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
var provider = new PythonUvProvider(pyprojectPath);

String exportOutput =
"# This file was autogenerated by uv\n"
+ "./local-package\n"
+ " # via test-project\n"
+ "../sibling-package\n"
+ " # via test-project\n"
+ "/absolute/path/package\n"
+ " # via test-project\n"
+ "~/home-relative/package\n"
+ " # via test-project\n"
+ "C:\\Users\\dev\\my-package\n"
+ " # via test-project\n"
+ "anyio==3.6.2\n"
+ " # via test-project\n";

// Given/When
var data = provider.parseUvExport(exportOutput);

// Then — all path dependency forms are skipped
assertThat(data.graph()).doesNotContainKey("./local-package");
assertThat(data.graph()).doesNotContainKey("../sibling-package");
assertThat(data.graph()).doesNotContainKey("/absolute/path/package");
assertThat(data.graph()).doesNotContainKey("~/home-relative/package");
assertThat(data.graph()).doesNotContainKey("c:\\users\\dev\\my-package");
// anyio is still parsed correctly
assertThat(data.graph()).containsKey("anyio");
assertThat(data.directDeps()).contains("anyio");
}

/**
* Verifies that # via comments after a skipped path dependency do not corrupt the graph by
* attaching to the previous package.
*/
@Test
void test_parseUvExport_via_after_skipped_path_dep_does_not_corrupt_graph() throws IOException {
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
var provider = new PythonUvProvider(pyprojectPath);

// Given — anyio is parsed first, then a path dependency is skipped. The "# via requests"
// after the skipped path dep should NOT make anyio a child of requests.
String exportOutput =
"# This file was autogenerated by uv\n"
+ "anyio==3.6.2\n"
+ " # via test-project\n"
+ "./local-package\n"
+ " # via requests\n"
+ "requests==2.25.1\n"
+ " # via test-project\n";

// When
var data = provider.parseUvExport(exportOutput);

// Then — requests should NOT have anyio as a child (that would be a corruption)
assertThat(data.graph().get("requests").children()).doesNotContain("anyio");
// Both anyio and requests are direct deps
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "requests");
}

/**
* Verifies that # via comments after skipped direct references do not create incorrect
* parent-child relationships with the previous package.
*/
@Test
void test_parseUvExport_via_after_skipped_does_not_corrupt_graph() throws IOException {
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
var provider = new PythonUvProvider(pyprojectPath);

// anyio is parsed first, then a direct reference is skipped. The "# via requests" after
// the skipped package should NOT make anyio a child of requests.
String exportOutput =
"# This file was autogenerated by uv\n"
+ "anyio==3.6.2\n"
+ " # via test-project\n"
+ "certifi @ git+https://github.com/certifi/python-certifi.git@abcdef\n"
+ " # via requests\n"
+ "requests==2.25.1\n"
+ " # via test-project\n";

// Given/When
var data = provider.parseUvExport(exportOutput);

// Then — requests should NOT have anyio as a child (that would be a corruption)
assertThat(data.graph().get("requests").children()).doesNotContain("anyio");
// certifi is skipped
assertThat(data.graph()).doesNotContainKey("certifi");
// Both anyio and requests are direct deps
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "requests");
}

/**
* Verifies that parseUvExport correctly handles a fixture file containing both direct references
* and path dependencies mixed with normal packages.
*/
@Test
void test_parseUvExport_with_direct_refs_fixture() throws IOException {
Path exportPath = Path.of(UV_FIXTURE, "uv_export_direct_refs.txt");
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
var provider = new PythonUvProvider(pyprojectPath);
String exportOutput = Files.readString(exportPath);

// Given/When
var data = provider.parseUvExport(exportOutput);

// Then — direct reference and path dependency are skipped
assertThat(data.graph()).doesNotContainKey("certifi");
assertThat(data.graph()).doesNotContainKey("./local-package");

// Normal packages are parsed correctly
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");
assertThat(data.graph().get("requests").version()).isEqualTo("2.25.1");

// Direct deps are correctly identified
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "flask", "requests");

// charset-normalizer is a child of requests (not corrupted by the skipped certifi)
assertThat(data.graph().get("requests").children()).contains("charset-normalizer");
}

/**
* Verifies that provideStack succeeds with export output containing direct references and path
* dependencies.
*/
@Test
void test_provideStack_with_direct_refs() throws IOException {
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export_direct_refs.txt"));

System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
try {
var provider = new PythonUvProvider(pyprojectPath);
var content = provider.provideStack();
assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
String sbomJson = new String(content.buffer);
assertThat(sbomJson).contains("CycloneDX");
// Skipped packages should not appear
assertThat(sbomJson).doesNotContain("pkg:pypi/certifi@");
// Normal packages should appear
assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
} catch (RuntimeException | NoClassDefFoundError e) {
Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
} finally {
System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
}
}

/**
* Verifies that provideComponent succeeds with export output containing direct references and
* path dependencies.
*/
@Test
void test_provideComponent_with_direct_refs() throws IOException {
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export_direct_refs.txt"));

System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
try {
var provider = new PythonUvProvider(pyprojectPath);
var content = provider.provideComponent();
assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
String sbomJson = new String(content.buffer);
assertThat(sbomJson).contains("CycloneDX");
assertThat(sbomJson).doesNotContain("pkg:pypi/certifi@");
assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
} catch (RuntimeException | NoClassDefFoundError e) {
Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
} finally {
System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
}
}

@Test
void test_parseUvExport_throws_on_unpinned_version() {
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 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 @ git+https://github.com/certifi/python-certifi.git@abcdef1234567890
# via requests
./local-package
# via test-project
charset-normalizer==3.1.0
# via requests
flask==2.0.3
# via test-project
idna==3.4
# via
# anyio
# requests
requests==2.25.1
# via test-project
sniffio==1.3.0
# via anyio
Loading