Skip to content

Commit 5482e17

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

4 files changed

Lines changed: 242 additions & 6 deletions

File tree

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

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
*/
1717
package io.github.guacsec.trustifyda.providers;
1818

19+
import io.github.guacsec.trustifyda.tools.Operations;
20+
import io.github.guacsec.trustifyda.utils.Environment;
21+
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
1922
import java.nio.file.Files;
2023
import java.nio.file.Path;
2124
import java.util.Map;
@@ -32,22 +35,81 @@ public final class PythonProviderFactory {
3235

3336
/**
3437
* 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).
38+
* in the manifest directory and parent directories (for workspace members). When {@code uv.lock}
39+
* is present, returns a {@link PythonUvProvider}; otherwise falls back to {@link
40+
* PythonPyprojectProvider} (pip-based).
3741
*
3842
* @param manifestPath the path to the pyproject.toml manifest
3943
* @return the matching Python provider
4044
*/
4145
public static PythonProvider create(final Path manifestPath) {
4246
var manifestDir = manifestPath.getParent();
4347

48+
// Check manifest directory first (fast path)
4449
for (var entry : PYTHON_PROVIDERS.entrySet()) {
4550
if (Files.isRegularFile(manifestDir.resolve(entry.getKey()))) {
4651
return entry.getValue().apply(manifestPath);
4752
}
4853
}
4954

55+
// Walk up parent directories to find lock file at workspace root
56+
Path lockFileDir = findLockFileDirInParents(manifestDir);
57+
if (lockFileDir != null) {
58+
for (var entry : PYTHON_PROVIDERS.entrySet()) {
59+
if (Files.isRegularFile(lockFileDir.resolve(entry.getKey()))) {
60+
return entry.getValue().apply(manifestPath);
61+
}
62+
}
63+
}
64+
5065
// Unlike JavaScript, pip fallback is valid — no lock file required
5166
return new PythonPyprojectProvider(manifestPath);
5267
}
68+
69+
/**
70+
* Walks up from the given directory to find a parent containing a known Python lock file.
71+
* Respects {@code TRUSTIFY_DA_WORKSPACE_DIR} override, stops at uv workspace root boundaries and
72+
* the git root.
73+
*
74+
* @param startDir the directory to start searching from (typically the manifest directory)
75+
* @return the directory containing the lock file, or {@code null} if not found
76+
*/
77+
static Path findLockFileDirInParents(Path startDir) {
78+
// Environment override takes precedence
79+
String workspaceDirOverride = Environment.get("TRUSTIFY_DA_WORKSPACE_DIR");
80+
if (workspaceDirOverride != null && !workspaceDirOverride.isBlank()) {
81+
Path overrideDir = Path.of(workspaceDirOverride);
82+
for (String lockFile : PYTHON_PROVIDERS.keySet()) {
83+
if (Files.isRegularFile(overrideDir.resolve(lockFile))) {
84+
return overrideDir;
85+
}
86+
}
87+
return null;
88+
}
89+
90+
String gitRoot = Operations.getGitRootDir(startDir.toString()).orElse(null);
91+
Path boundary = gitRoot != null ? Path.of(gitRoot) : startDir.toAbsolutePath().getRoot();
92+
93+
Path current = startDir.toAbsolutePath().normalize().getParent();
94+
while (current != null) {
95+
for (String lockFile : PYTHON_PROVIDERS.keySet()) {
96+
if (Files.isRegularFile(current.resolve(lockFile))) {
97+
return current;
98+
}
99+
}
100+
101+
// Stop at uv workspace root boundary
102+
if (PyprojectTomlUtils.isUvWorkspaceRoot(current)) {
103+
return null;
104+
}
105+
106+
if (current.equals(boundary.toAbsolutePath().normalize())) {
107+
break;
108+
}
109+
110+
current = current.getParent();
111+
}
112+
113+
return null;
114+
}
53115
}

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,17 @@ public PythonUvProvider(Path manifest) {
6767

6868
@Override
6969
public void validateLockFile(Path lockFileDir) {
70-
if (!Files.isRegularFile(lockFileDir.resolve(LOCK_FILE))) {
71-
throw new IllegalStateException(
72-
"uv.lock does not exist. Ensure the project is managed by uv"
73-
+ " and run 'uv lock' to generate it.");
70+
// Check manifest directory first, then walk up to workspace root
71+
if (Files.isRegularFile(lockFileDir.resolve(LOCK_FILE))) {
72+
return;
73+
}
74+
Path parentDir = PythonProviderFactory.findLockFileDirInParents(lockFileDir);
75+
if (parentDir != null && Files.isRegularFile(parentDir.resolve(LOCK_FILE))) {
76+
return;
7477
}
78+
throw new IllegalStateException(
79+
"uv.lock does not exist. Ensure the project is managed by uv"
80+
+ " and run 'uv lock' to generate it.");
7581
}
7682

7783
@Override

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,21 @@ public static boolean hasPoetryDependencies(TomlParseResult toml) {
112112
public static String canonicalize(String name) {
113113
return name.toLowerCase().replaceAll("[-_.]+", "-");
114114
}
115+
116+
/**
117+
* Returns {@code true} if the directory contains a {@code pyproject.toml} with a {@code
118+
* [tool.uv.workspace]} section, indicating a uv workspace root.
119+
*/
120+
public static boolean isUvWorkspaceRoot(Path dir) {
121+
Path pyprojectPath = dir.resolve("pyproject.toml");
122+
if (!Files.isRegularFile(pyprojectPath)) {
123+
return false;
124+
}
125+
try {
126+
TomlParseResult toml = Toml.parse(pyprojectPath);
127+
return toml.getTable("tool.uv.workspace") != null;
128+
} catch (Exception e) {
129+
return false;
130+
}
131+
}
115132
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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 static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatCode;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
22+
23+
import java.io.IOException;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
26+
import org.junit.jupiter.api.Test;
27+
import org.junit.jupiter.api.io.TempDir;
28+
import org.junitpioneer.jupiter.ClearSystemProperty;
29+
30+
/**
31+
* Tests for PythonProviderFactory lock file walk-up. Verifies that the factory can find uv.lock in
32+
* parent directories for workspace member packages.
33+
*/
34+
class PythonProviderFactoryLockFileTest {
35+
36+
private static final String PYPROJECT_TOML =
37+
"[project]\nname = \"my-lib\"\nversion = \"1.0.0\"\ndependencies = []\n";
38+
39+
/** Lock file in manifest directory (existing behavior — fast path). */
40+
@Test
41+
void testLockFileInManifestDir(@TempDir Path tempDir) throws IOException {
42+
Files.writeString(tempDir.resolve("pyproject.toml"), PYPROJECT_TOML);
43+
Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
44+
45+
var provider = PythonProviderFactory.create(tempDir.resolve("pyproject.toml"));
46+
assertThat(provider).isInstanceOf(PythonUvProvider.class);
47+
}
48+
49+
/** No lock file — falls back to pip provider. */
50+
@Test
51+
void testNoLockFileFallsToPip(@TempDir Path tempDir) throws IOException {
52+
Files.writeString(tempDir.resolve("pyproject.toml"), PYPROJECT_TOML);
53+
54+
var provider = PythonProviderFactory.create(tempDir.resolve("pyproject.toml"));
55+
assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
56+
}
57+
58+
/** Lock file at workspace root, member pyproject.toml in subdirectory. */
59+
@Test
60+
void testLockFileFoundInParentDir(@TempDir Path tempDir) throws IOException {
61+
// Workspace root with uv workspace config and lock file
62+
Files.writeString(
63+
tempDir.resolve("pyproject.toml"),
64+
"[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n"
65+
+ "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
66+
Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
67+
68+
// Member package without its own lock file
69+
Path memberDir = tempDir.resolve("packages/my-lib");
70+
Files.createDirectories(memberDir);
71+
Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
72+
73+
var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml"));
74+
assertThat(provider).isInstanceOf(PythonUvProvider.class);
75+
}
76+
77+
/** TRUSTIFY_DA_WORKSPACE_DIR overrides walk-up. */
78+
@Test
79+
@ClearSystemProperty(key = "TRUSTIFY_DA_WORKSPACE_DIR")
80+
void testWorkspaceDirOverride(@TempDir Path tempDir) throws IOException {
81+
// Custom dir with uv lock file
82+
Path customDir = tempDir.resolve("custom-root");
83+
Files.createDirectories(customDir);
84+
Files.writeString(customDir.resolve("uv.lock"), "version = 1");
85+
86+
// Member without lock file
87+
Path memberDir = tempDir.resolve("packages/svc");
88+
Files.createDirectories(memberDir);
89+
Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
90+
91+
System.setProperty("TRUSTIFY_DA_WORKSPACE_DIR", customDir.toString());
92+
try {
93+
var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml"));
94+
assertThat(provider).isInstanceOf(PythonUvProvider.class);
95+
} finally {
96+
System.clearProperty("TRUSTIFY_DA_WORKSPACE_DIR");
97+
}
98+
}
99+
100+
/** Walk-up stops at uv workspace root boundary without lock file — falls back to pip. */
101+
@Test
102+
void testStopsAtUvWorkspaceRootBoundary(@TempDir Path tempDir) throws IOException {
103+
// Workspace root with uv workspace config but NO lock file
104+
Files.writeString(
105+
tempDir.resolve("pyproject.toml"),
106+
"[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n"
107+
+ "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
108+
109+
// Member package
110+
Path memberDir = tempDir.resolve("packages/lib");
111+
Files.createDirectories(memberDir);
112+
Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
113+
114+
// Should fall back to pip, not keep walking up
115+
var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml"));
116+
assertThat(provider).isInstanceOf(PythonPyprojectProvider.class);
117+
}
118+
119+
/** validateLockFile passes for workspace member when uv.lock is in parent directory. */
120+
@Test
121+
void testValidateLockFilePassesWithLockInParent(@TempDir Path tempDir) throws IOException {
122+
// Workspace root with uv.lock
123+
Files.writeString(
124+
tempDir.resolve("pyproject.toml"),
125+
"[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n"
126+
+ "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n");
127+
Files.writeString(tempDir.resolve("uv.lock"), "version = 1");
128+
129+
// Member package without its own lock file
130+
Path memberDir = tempDir.resolve("packages/my-lib");
131+
Files.createDirectories(memberDir);
132+
Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
133+
134+
var provider = new PythonUvProvider(memberDir.resolve("pyproject.toml"));
135+
// Ecosystem.getProvider() calls validateLockFile(manifestPath.getParent())
136+
assertThatCode(() -> provider.validateLockFile(memberDir)).doesNotThrowAnyException();
137+
}
138+
139+
/** validateLockFile throws when uv.lock is not found anywhere. */
140+
@Test
141+
void testValidateLockFileThrowsWhenNotFound(@TempDir Path tempDir) throws IOException {
142+
Path memberDir = tempDir.resolve("packages/my-lib");
143+
Files.createDirectories(memberDir);
144+
Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML);
145+
146+
var provider = new PythonUvProvider(memberDir.resolve("pyproject.toml"));
147+
assertThatThrownBy(() -> provider.validateLockFile(memberDir))
148+
.isInstanceOf(IllegalStateException.class)
149+
.hasMessageContaining("uv.lock does not exist");
150+
}
151+
}

0 commit comments

Comments
 (0)