Skip to content

Commit 9918286

Browse files
Strum355claude
andcommitted
feat(workspace): add uv workspace (pyproject.toml + uv.lock) discovery
Parse [tool.uv.workspace] member and exclude globs from pyproject.toml to discover all workspace member manifests. Virtual workspaces (no [project] section) correctly exclude the root pyproject.toml. Adds __pycache__ and .venv to default ignore patterns. TC-4266 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 68de12e commit 9918286

21 files changed

Lines changed: 324 additions & 1 deletion

File tree

src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import io.github.guacsec.trustifyda.tools.Ecosystem;
3737
import io.github.guacsec.trustifyda.tools.Operations;
3838
import io.github.guacsec.trustifyda.utils.Environment;
39+
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
3940
import io.github.guacsec.trustifyda.utils.WorkspaceUtils;
4041
import jakarta.mail.MessagingException;
4142
import jakarta.mail.internet.MimeMultipart;
@@ -50,8 +51,10 @@
5051
import java.net.http.HttpRequest;
5152
import java.net.http.HttpResponse;
5253
import java.nio.charset.StandardCharsets;
54+
import java.nio.file.FileSystems;
5355
import java.nio.file.Files;
5456
import java.nio.file.Path;
57+
import java.nio.file.PathMatcher;
5558
import java.time.LocalDateTime;
5659
import java.time.temporal.ChronoUnit;
5760
import java.util.AbstractMap;
@@ -69,6 +72,9 @@
6972
import java.util.function.Supplier;
7073
import java.util.logging.Logger;
7174
import java.util.stream.Collectors;
75+
import org.tomlj.TomlArray;
76+
import org.tomlj.TomlParseResult;
77+
import org.tomlj.TomlTable;
7278

7379
/** Concrete implementation of the Exhort {@link Api} Service. */
7480
public final class ExhortApi implements Api {
@@ -843,7 +849,7 @@ int resolveBatchConcurrency() {
843849
}
844850

845851
private static final Set<String> DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
846-
Set.of("**/node_modules/**", "**/.git/**");
852+
Set.of("**/node_modules/**", "**/.git/**", "**/__pycache__/**", "**/.venv/**");
847853

848854
/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
849855
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
@@ -884,6 +890,15 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
884890
}
885891
}
886892

893+
// uv workspace: pyproject.toml with [tool.uv.workspace] + uv.lock
894+
if (Files.isRegularFile(workspaceDir.resolve("pyproject.toml"))
895+
&& Files.isRegularFile(workspaceDir.resolve("uv.lock"))) {
896+
List<Path> uvManifests = discoverUvWorkspaceMembers(workspaceDir, ignorePatterns);
897+
if (!uvManifests.isEmpty()) {
898+
return uvManifests;
899+
}
900+
}
901+
887902
// JS workspace: require package.json + a lock file
888903
Path packageJson = workspaceDir.resolve("package.json");
889904
boolean hasJsLock =
@@ -976,6 +991,87 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
976991
}
977992
}
978993

994+
/**
995+
* Discover all pyproject.toml manifest paths in a uv workspace. Parses the root pyproject.toml
996+
* for {@code [tool.uv.workspace]} member and exclude globs, then walks the filesystem to find
997+
* matching member directories.
998+
*/
999+
private List<Path> discoverUvWorkspaceMembers(Path workspaceDir, Set<String> ignorePatterns) {
1000+
try {
1001+
Path rootPyproject = workspaceDir.resolve("pyproject.toml");
1002+
TomlParseResult toml = PyprojectTomlUtils.parseToml(rootPyproject);
1003+
1004+
TomlTable workspaceConfig = toml.getTable("tool.uv.workspace");
1005+
if (workspaceConfig == null) {
1006+
return Collections.emptyList();
1007+
}
1008+
1009+
TomlArray membersArray = workspaceConfig.getArray("members");
1010+
if (membersArray == null || membersArray.isEmpty()) {
1011+
return Collections.emptyList();
1012+
}
1013+
1014+
List<String> memberPatterns = new ArrayList<>();
1015+
for (int i = 0; i < membersArray.size(); i++) {
1016+
String pattern = membersArray.getString(i);
1017+
if (pattern != null && !pattern.isBlank()) {
1018+
memberPatterns.add(pattern.trim());
1019+
}
1020+
}
1021+
if (memberPatterns.isEmpty()) {
1022+
return Collections.emptyList();
1023+
}
1024+
1025+
Set<String> excludePatterns = new java.util.HashSet<>();
1026+
TomlArray excludeArray = workspaceConfig.getArray("exclude");
1027+
if (excludeArray != null) {
1028+
for (int i = 0; i < excludeArray.size(); i++) {
1029+
String pattern = excludeArray.getString(i);
1030+
if (pattern != null && !pattern.isBlank()) {
1031+
excludePatterns.add(pattern.trim());
1032+
}
1033+
}
1034+
}
1035+
1036+
List<PathMatcher> memberMatchers =
1037+
memberPatterns.stream()
1038+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
1039+
.toList();
1040+
1041+
List<PathMatcher> excludeMatchers =
1042+
excludePatterns.stream()
1043+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
1044+
.toList();
1045+
1046+
List<Path> manifests = new ArrayList<>();
1047+
try (var stream = Files.walk(workspaceDir)) {
1048+
stream
1049+
.filter(p -> p.getFileName().toString().equals("pyproject.toml"))
1050+
.filter(p -> !p.equals(rootPyproject))
1051+
.forEach(
1052+
p -> {
1053+
Path relative = workspaceDir.relativize(p.getParent());
1054+
boolean matchesMember =
1055+
memberMatchers.stream().anyMatch(m -> m.matches(relative));
1056+
boolean matchesExclude =
1057+
excludeMatchers.stream().anyMatch(m -> m.matches(relative));
1058+
if (matchesMember && !matchesExclude) {
1059+
manifests.add(p);
1060+
}
1061+
});
1062+
}
1063+
1064+
if (PyprojectTomlUtils.getProjectName(toml) != null) {
1065+
manifests.addFirst(rootPyproject);
1066+
}
1067+
1068+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns);
1069+
} catch (Exception e) {
1070+
LOG.warning("Failed to discover uv workspace members: " + e.getMessage());
1071+
return Collections.emptyList();
1072+
}
1073+
}
1074+
9791075
/**
9801076
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
9811077
* publishable package.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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.impl;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import java.io.File;
22+
import java.io.IOException;
23+
import java.nio.file.Path;
24+
import java.util.List;
25+
import java.util.Set;
26+
import org.junit.jupiter.api.Test;
27+
import org.mockito.Mockito;
28+
29+
class UvWorkspaceDiscoveryTest {
30+
31+
private static final Path UV_FIXTURES = Path.of("src/test/resources/tst_manifests/workspace/uv");
32+
33+
@Test
34+
void discoverWorkspaceManifests_uvRootPackageWorkspace() throws IOException {
35+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace").toAbsolutePath().normalize();
36+
37+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
38+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
39+
40+
assertThat(manifests).hasSize(3);
41+
assertThat(manifests).allMatch(p -> p.toString().endsWith("pyproject.toml"));
42+
assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pyproject.toml"));
43+
assertThat(manifests)
44+
.anyMatch(
45+
p ->
46+
p.toString()
47+
.contains(
48+
"packages"
49+
+ File.separator
50+
+ "mid-pkg"
51+
+ File.separator
52+
+ "pyproject.toml"));
53+
assertThat(manifests)
54+
.anyMatch(
55+
p ->
56+
p.toString()
57+
.contains(
58+
"packages"
59+
+ File.separator
60+
+ "sub-pkg"
61+
+ File.separator
62+
+ "pyproject.toml"));
63+
}
64+
65+
@Test
66+
void discoverWorkspaceManifests_uvVirtualWorkspace() throws IOException {
67+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_virtual").toAbsolutePath().normalize();
68+
69+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
70+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
71+
72+
assertThat(manifests).hasSize(2);
73+
assertThat(manifests).allMatch(p -> p.toString().endsWith("pyproject.toml"));
74+
assertThat(manifests).noneMatch(p -> p.equals(workspaceDir.resolve("pyproject.toml")));
75+
assertThat(manifests).anyMatch(p -> p.toString().contains("pkg-a"));
76+
assertThat(manifests).anyMatch(p -> p.toString().contains("pkg-b"));
77+
}
78+
79+
@Test
80+
void discoverWorkspaceManifests_uvExcludePatterns() throws IOException {
81+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_exclude").toAbsolutePath().normalize();
82+
83+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
84+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
85+
86+
assertThat(manifests).anyMatch(p -> p.toString().contains("core"));
87+
assertThat(manifests).noneMatch(p -> p.toString().contains("internal"));
88+
}
89+
90+
@Test
91+
void discoverWorkspaceManifests_uvNestedMultiplePatterns() throws IOException {
92+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_nested").toAbsolutePath().normalize();
93+
94+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
95+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
96+
97+
assertThat(manifests)
98+
.anyMatch(
99+
p ->
100+
p.toString()
101+
.contains(
102+
"apps" + File.separator + "backend" + File.separator + "pyproject.toml"));
103+
assertThat(manifests)
104+
.anyMatch(
105+
p ->
106+
p.toString()
107+
.contains(
108+
"libs" + File.separator + "core" + File.separator + "pyproject.toml"));
109+
}
110+
111+
@Test
112+
void discoverWorkspaceManifests_uvNoLockFile() throws IOException {
113+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_no_lock").toAbsolutePath().normalize();
114+
115+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
116+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
117+
118+
assertThat(manifests).isEmpty();
119+
}
120+
121+
@Test
122+
void discoverWorkspaceManifests_uvNoWorkspaceConfig() throws IOException {
123+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_no_config").toAbsolutePath().normalize();
124+
125+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
126+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
127+
128+
assertThat(manifests).isEmpty();
129+
}
130+
131+
@Test
132+
void discoverWorkspaceManifests_uvIgnorePatternFiltering() throws IOException {
133+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_nested").toAbsolutePath().normalize();
134+
135+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
136+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/libs/**"));
137+
138+
assertThat(manifests).anyMatch(p -> p.toString().contains("backend"));
139+
assertThat(manifests)
140+
.noneMatch(p -> p.toString().contains(File.separator + "libs" + File.separator));
141+
}
142+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "mid-pkg"
3+
version = "0.1.0"
4+
dependencies = [
5+
"sub-pkg",
6+
]
7+
8+
[tool.uv.sources]
9+
sub-pkg = { workspace = true }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "sub-pkg"
3+
version = "0.1.0"
4+
dependencies = [
5+
"requests>=2.0",
6+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "uv-mono"
3+
version = "0.1.0"
4+
dependencies = [
5+
"mid-pkg",
6+
"flask==2.0.3"
7+
]
8+
9+
[tool.uv.sources]
10+
mid-pkg = { workspace = true }
11+
12+
[tool.uv.workspace]
13+
members = ["packages/*"]

src/test/resources/tst_manifests/workspace/uv/uv_workspace/uv.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "core"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "internal"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[project]
2+
name = "exclude-workspace"
3+
version = "0.1.0"
4+
dependencies = []
5+
6+
[tool.uv.workspace]
7+
members = ["packages/*"]
8+
exclude = ["packages/internal"]

src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/uv.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)