Skip to content

Commit 7ca7c02

Browse files
a-orenclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 5482e17 commit 7ca7c02

5 files changed

Lines changed: 270 additions & 3 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ static Path findLockFileDirInParents(Path startDir) {
8787
return null;
8888
}
8989

90+
// If startDir itself is a workspace root, don't walk up to avoid escaping into a parent
91+
// workspace
92+
if (PyprojectTomlUtils.isUvWorkspaceRoot(startDir)) {
93+
return null;
94+
}
95+
9096
String gitRoot = Operations.getGitRootDir(startDir.toString()).orElse(null);
9197
Path boundary = gitRoot != null ? Path.of(gitRoot) : startDir.toAbsolutePath().getRoot();
9298

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
2828
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
2929
import java.io.IOException;
30+
import java.net.URI;
3031
import java.nio.charset.StandardCharsets;
3132
import java.nio.file.Files;
3233
import java.nio.file.Path;
@@ -37,6 +38,7 @@
3738
import java.util.Map;
3839
import java.util.Set;
3940
import java.util.logging.Logger;
41+
import java.util.regex.Pattern;
4042
import java.util.stream.Collectors;
4143
import org.tomlj.TomlParseResult;
4244

@@ -181,16 +183,19 @@ UvDependencyData parseUvExport(String exportOutput) throws IOException {
181183
continue;
182184
}
183185

184-
// Skip editable installs (workspace members)
186+
// Editable installs are workspace members — resolve name/version from their pyproject.toml
185187
if (line.startsWith("-e ")) {
186-
currentKey = null;
187188
inViaBlock = false;
189+
currentKey = parseEditableInstall(line, packages, projectName);
188190
continue;
189191
}
190192

191193
// Package line: name==version [; env-marker]
192-
if (!line.startsWith(" ") && trimmed.contains("==")) {
194+
if (!line.startsWith(" ") && !trimmed.startsWith("#")) {
193195
inViaBlock = false;
196+
if (!trimmed.contains("==")) {
197+
throw new IOException("uv export: package '" + trimmed + "' has no pinned version");
198+
}
194199
String spec = trimmed.split(";")[0].trim();
195200
String[] parts = spec.split("==", 2);
196201
String name = parts[0].split("\\[")[0].trim(); // strip extras like [bar]
@@ -230,12 +235,66 @@ UvDependencyData parseUvExport(String exportOutput) throws IOException {
230235
return new UvDependencyData(directDeps, packages);
231236
}
232237

238+
/**
239+
* Parses an editable install line ({@code -e file:///path/to/member}) by reading the member's
240+
* {@code pyproject.toml} to extract name and version. Skips the root project itself and packages
241+
* missing either name or version, matching the JS client behavior.
242+
*
243+
* @return the canonicalized package key, or {@code null} if the member could not be resolved
244+
*/
245+
private static String parseEditableInstall(
246+
String line, Map<String, UvPackage> packages, String projectName) {
247+
String uri = line.substring("-e ".length()).trim();
248+
try {
249+
Path memberDir = Path.of(URI.create(uri));
250+
Path memberToml = memberDir.resolve("pyproject.toml");
251+
if (!Files.isRegularFile(memberToml)) {
252+
log.fine("Editable install pyproject.toml not found: " + memberToml);
253+
return null;
254+
}
255+
TomlParseResult toml = PyprojectTomlUtils.parseToml(memberToml);
256+
String name = PyprojectTomlUtils.getProjectName(toml);
257+
if (name == null) {
258+
// Fall back to Poetry name
259+
name = PyprojectTomlUtils.getPoetryProjectName(toml);
260+
}
261+
if (name == null) {
262+
log.fine("Editable install has no project.name: " + memberToml);
263+
return null;
264+
}
265+
String version = PyprojectTomlUtils.getProjectVersion(toml);
266+
if (version == null) {
267+
// Fall back to Poetry version
268+
version = PyprojectTomlUtils.getPoetryProjectVersion(toml);
269+
}
270+
if (version == null) {
271+
log.fine("Editable install has no project.version: " + memberToml);
272+
return null;
273+
}
274+
String key = PyprojectTomlUtils.canonicalize(name);
275+
// Skip the root project itself
276+
if (key.equals(projectName)) {
277+
return null;
278+
}
279+
packages.put(key, new UvPackage(name, version, new ArrayList<>()));
280+
return key;
281+
} catch (Exception e) {
282+
log.fine("Failed to resolve editable install '" + uri + "': " + e.getMessage());
283+
return null;
284+
}
285+
}
286+
287+
private static final Pattern BARE_PACKAGE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*");
288+
233289
private static void recordViaParent(
234290
String parentName,
235291
String childKey,
236292
String projectName,
237293
List<String> directDeps,
238294
List<String[]> parentChildPairs) {
295+
if (!BARE_PACKAGE_NAME.matcher(parentName).matches()) {
296+
return;
297+
}
239298
String parentKey = PyprojectTomlUtils.canonicalize(parentName);
240299
if (parentKey.equals(projectName)) {
241300
if (!directDeps.contains(childKey)) {

src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ public static boolean hasPoetryDependencies(TomlParseResult toml) {
105105
return toml.getTable("tool.poetry.dependencies") != null;
106106
}
107107

108+
/** Reads {@code tool.poetry.name} from a parsed pyproject.toml, or {@code null} if absent. */
109+
public static String getPoetryProjectName(TomlParseResult toml) {
110+
String name = toml.getString("tool.poetry.name");
111+
return (name != null && !name.isBlank()) ? name : null;
112+
}
113+
114+
/** Reads {@code tool.poetry.version} from a parsed pyproject.toml, or {@code null} if absent. */
115+
public static String getPoetryProjectVersion(TomlParseResult toml) {
116+
String version = toml.getString("tool.poetry.version");
117+
return (version != null && !version.isBlank()) ? version : null;
118+
}
119+
108120
/**
109121
* Canonicalizes a Python package name by lower-casing it and collapsing runs of hyphens,
110122
* underscores, and dots into a single hyphen, per PEP 503.

src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,29 @@ void testStopsAtUvWorkspaceRootBoundary(@TempDir Path tempDir) throws IOExceptio
116116
assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
117117
}
118118

119+
/** When manifestDir itself is a workspace root without uv.lock, don't walk up to parent. */
120+
@Test
121+
void testStartDirIsWorkspaceRootDoesNotWalkUp(@TempDir Path tempDir) throws IOException {
122+
// Parent has uv.lock (unrelated workspace)
123+
Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
124+
Files.writeString(
125+
tempDir.resolve("pyproject.toml"),
126+
"[project]\nname = \"parent-ws\"\nversion = \"1.0.0\"\n\n"
127+
+ "[tool.uv.workspace]\nmembers = [\"child-ws\"]\n");
128+
129+
// Child is itself a workspace root, but has no uv.lock
130+
Path childWs = tempDir.resolve("child-ws");
131+
Files.createDirectories(childWs);
132+
Files.writeString(
133+
childWs.resolve("pyproject.toml"),
134+
"[project]\nname = \"child-ws\"\nversion = \"1.0.0\"\n\n"
135+
+ "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
136+
137+
// Should NOT pick up the parent's uv.lock — should fall back to pip
138+
var provider = PythonProviderFactory.create(childWs.resolve("pyproject.toml"));
139+
assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
140+
}
141+
119142
/** validateLockFile passes for workspace member when uv.lock is in parent directory. */
120143
@Test
121144
void testValidateLockFilePassesWithLockInParent(@TempDir Path tempDir) throws IOException {

src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import static org.assertj.core.api.Assertions.assertThat;
2020
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2122

2223
import io.github.guacsec.trustifyda.Api;
2324
import io.github.guacsec.trustifyda.ExhortTest;
@@ -28,6 +29,7 @@
2829
import java.nio.file.Path;
2930
import org.junit.jupiter.api.Assumptions;
3031
import org.junit.jupiter.api.Test;
32+
import org.junit.jupiter.api.io.TempDir;
3133

3234
class Python_Uv_Provider_Test extends ExhortTest {
3335

@@ -238,6 +240,171 @@ void test_ignored_dependencies_in_uv_project() throws IOException {
238240
}
239241
}
240242

243+
@Test
244+
void test_parseUvExport_includes_editable_installs(@TempDir Path tempDir) throws IOException {
245+
// Create a workspace member with its own pyproject.toml
246+
Path memberDir = tempDir.resolve("packages").resolve("my-lib");
247+
Files.createDirectories(memberDir);
248+
Files.writeString(
249+
memberDir.resolve("pyproject.toml"),
250+
"[project]\nname = \"my-lib\"\nversion = \"2.0.0\"\ndependencies = []\n");
251+
252+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
253+
var provider = new PythonUvProvider(pyprojectPath);
254+
255+
// Simulate uv export output with an editable install
256+
String exportOutput =
257+
"# This file was autogenerated by uv\n"
258+
+ "-e file://"
259+
+ memberDir.toUri().getPath()
260+
+ "\n"
261+
+ " # via test-project\n"
262+
+ "anyio==3.6.2\n"
263+
+ " # via my-lib\n";
264+
265+
var data = provider.parseUvExport(exportOutput);
266+
267+
// The editable install should be in the graph with name/version from pyproject.toml
268+
assertThat(data.graph()).containsKey("my-lib");
269+
assertThat(data.graph().get("my-lib").name()).isEqualTo("my-lib");
270+
assertThat(data.graph().get("my-lib").version()).isEqualTo("2.0.0");
271+
272+
// It should be identified as a direct dependency (via test-project)
273+
assertThat(data.directDeps()).contains("my-lib");
274+
275+
// anyio should be a child of my-lib
276+
assertThat(data.graph().get("my-lib").children()).contains("anyio");
277+
}
278+
279+
@Test
280+
void test_parseUvExport_editable_skips_self_reference(@TempDir Path tempDir) throws IOException {
281+
// Create a member whose name matches the root project (test-project)
282+
Path memberDir = tempDir.resolve("packages").resolve("self");
283+
Files.createDirectories(memberDir);
284+
Files.writeString(
285+
memberDir.resolve("pyproject.toml"),
286+
"[project]\nname = \"test-project\"\nversion = \"0.1.0\"\ndependencies = []\n");
287+
288+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
289+
var provider = new PythonUvProvider(pyprojectPath);
290+
291+
String exportOutput =
292+
"# This file was autogenerated by uv\n"
293+
+ "-e file://"
294+
+ memberDir.toUri().getPath()
295+
+ "\n"
296+
+ "anyio==3.6.2\n"
297+
+ " # via test-project\n";
298+
299+
var data = provider.parseUvExport(exportOutput);
300+
301+
// The root project should NOT appear in the graph as its own dependency
302+
assertThat(data.graph()).doesNotContainKey("test-project");
303+
// anyio should still be a direct dep
304+
assertThat(data.directDeps()).contains("anyio");
305+
}
306+
307+
@Test
308+
void test_parseUvExport_editable_skips_missing_version(@TempDir Path tempDir) throws IOException {
309+
// Create a member with no version
310+
Path memberDir = tempDir.resolve("packages").resolve("no-version");
311+
Files.createDirectories(memberDir);
312+
Files.writeString(
313+
memberDir.resolve("pyproject.toml"),
314+
"[project]\nname = \"no-version-lib\"\ndependencies = []\n");
315+
316+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
317+
var provider = new PythonUvProvider(pyprojectPath);
318+
319+
String exportOutput =
320+
"# This file was autogenerated by uv\n"
321+
+ "-e file://"
322+
+ memberDir.toUri().getPath()
323+
+ "\n"
324+
+ " # via test-project\n"
325+
+ "anyio==3.6.2\n"
326+
+ " # via test-project\n";
327+
328+
var data = provider.parseUvExport(exportOutput);
329+
330+
// Package with no version should be skipped
331+
assertThat(data.graph()).doesNotContainKey("no-version-lib");
332+
// anyio should still be parsed
333+
assertThat(data.graph()).containsKey("anyio");
334+
}
335+
336+
@Test
337+
void test_parseUvExport_editable_falls_back_to_poetry_name(@TempDir Path tempDir)
338+
throws IOException {
339+
// Create a member with Poetry-style name/version only
340+
Path memberDir = tempDir.resolve("packages").resolve("poetry-lib");
341+
Files.createDirectories(memberDir);
342+
Files.writeString(
343+
memberDir.resolve("pyproject.toml"),
344+
"[tool.poetry]\nname = \"poetry-lib\"\nversion = \"1.5.0\"\n");
345+
346+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
347+
var provider = new PythonUvProvider(pyprojectPath);
348+
349+
String exportOutput =
350+
"# This file was autogenerated by uv\n"
351+
+ "-e file://"
352+
+ memberDir.toUri().getPath()
353+
+ "\n"
354+
+ " # via test-project\n"
355+
+ "anyio==3.6.2\n"
356+
+ " # via poetry-lib\n";
357+
358+
var data = provider.parseUvExport(exportOutput);
359+
360+
// Poetry name/version should be used as fallback
361+
assertThat(data.graph()).containsKey("poetry-lib");
362+
assertThat(data.graph().get("poetry-lib").name()).isEqualTo("poetry-lib");
363+
assertThat(data.graph().get("poetry-lib").version()).isEqualTo("1.5.0");
364+
365+
// It should be a direct dep and anyio should be its child
366+
assertThat(data.directDeps()).contains("poetry-lib");
367+
assertThat(data.graph().get("poetry-lib").children()).contains("anyio");
368+
}
369+
370+
@Test
371+
void test_parseUvExport_via_skips_non_bare_package_names() throws IOException {
372+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
373+
var provider = new PythonUvProvider(pyprojectPath);
374+
375+
String exportOutput =
376+
"# This file was autogenerated by uv\n"
377+
+ "anyio==3.6.2\n"
378+
+ " # via test-project\n"
379+
+ "idna==3.4\n"
380+
+ " # via foo (>=1.0)\n"
381+
+ "sniffio==1.3.0\n"
382+
+ " # via foo[extra]\n";
383+
384+
var data = provider.parseUvExport(exportOutput);
385+
386+
// anyio is direct (via test-project)
387+
assertThat(data.directDeps()).contains("anyio");
388+
// idna and sniffio should be in the graph but with no parent edges
389+
assertThat(data.graph()).containsKey("idna");
390+
assertThat(data.graph()).containsKey("sniffio");
391+
// No package should have children since the via names were invalid
392+
assertThat(data.graph().values().stream().allMatch(p -> p.children().isEmpty())).isTrue();
393+
}
394+
395+
@Test
396+
void test_parseUvExport_throws_on_unpinned_version() {
397+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
398+
var provider = new PythonUvProvider(pyprojectPath);
399+
400+
String exportOutput =
401+
"# This file was autogenerated by uv\n" + "anyio>=3.6.2\n" + " # via test-project\n";
402+
403+
assertThatThrownBy(() -> provider.parseUvExport(exportOutput))
404+
.isInstanceOf(IOException.class)
405+
.hasMessageContaining("has no pinned version");
406+
}
407+
241408
@Test
242409
void test_canonicalize() {
243410
assertThat(PyprojectTomlUtils.canonicalize("charset_normalizer"))

0 commit comments

Comments
 (0)